Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,19 @@ jobs:
steps:
- uses: actions/checkout@v6

- name: Build binary (Linux in Ubuntu 25.10 container)
- name: Build binary (Linux in Ubuntu 26.04 container)
if: matrix.name == 'linux'
shell: bash
run: |
# Docker container with Ubuntu 25.10 and build the binary inside it
# Docker container with Ubuntu 26.04 and build the binary inside it
rm -rf dist/*
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace \
ubuntu:25.10 \
ubuntu:26.04 \
bash -c "
set -e
echo 'Setting up Ubuntu 25.10 build environment...'
echo 'Setting up Ubuntu 26.04 build environment...'

apt-get update -y
apt-get install -y --fix-missing curl python3 python3-pip python3-venv binutils
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/mend-scan.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
name: Mend CLI Scan

on:
workflow_run:
workflows: ["Tests"]
types: [completed]
workflow_dispatch:
push:


jobs:
mend-scan:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
steps:
- name: Checkout code
uses: actions/checkout@v6
Expand Down
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,11 @@ wheels/
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360

.idea/
version_info.txt
version_info.txt

# Mend/WhiteSource artifacts
whitesource/
.whitesource.lock
mend
mend-cli
*.mend.json
47 changes: 47 additions & 0 deletions .whitesource
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"scanSettings": {
"configMode": "AUTO",
"configExternalURL": "",
"projectToken": "",
"baseBranches": []
},
"scanSettingsSAST": {
"enableScan": true,
"scanPullRequests": true,
"incrementalScan": true,
"findingSuppressions": "enabled",
"enableSecretsScan": true,
"baseBranches": [],
"snippetSize": 10
},
"checkRunSettings": {
"vulnerableCheckRunConclusionLevel": "failure",
"displayMode": "diff",
"useMendCheckNames": true
},
"checkRunSettingsSAST": {
"checkRunConclusionLevel": "failure",
"severityThreshold": "high"
},
"issueSettings": {
"minSeverityLevel": "LOW",
"issueType": "DEPENDENCY"
},
"issueSettingsSAST": {
"minSeverityLevel": "high",
"issueType": "repo"
},
"remediateSettings": {
"workflowRules": {
"enabled": true
}
},
"imageSettings":{
"imageTracing":{
"enableImageTracingPR": false,
"addRepositoryCoordinate": false,
"addDockerfilePath": false,
"addMendIdentifier": false
}
}
}
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

FROM ubuntu:25.10
FROM ubuntu:26.04

WORKDIR /app

Expand Down
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,74 @@ When using custom CA certificate bundles, you must configure both:

---

## OpenTelemetry

Perfecto MCP reports traces and metrics for MCP tool calls using [OpenTelemetry](https://opentelemetry.io/). This gives you visibility into which tools are used, how long they take, and when errors occur.

Telemetry is enabled by default. You do not need to configure anything unless you want to change where data is sent or turn it off.

### Default behavior

By default, telemetry is exported over gRPC to:

`https://grpc.public.prd.shared.perforce.com`

The service is identified as `perfecto-mcp` with the current release version. If telemetry fails to start or export, the MCP server continues to work as usual.

### Turn off telemetry

Add `OTEL_SDK_DISABLED=true` to your MCP client environment:

```json
"env": {
"OTEL_SDK_DISABLED": "true"
}
```

For Docker, pass it with `-e`:

```json
"-e",
"OTEL_SDK_DISABLED=true"
```

### Send data to your own collector

Use these environment variables to point at a different OpenTelemetry collector (for example, a local one during development):

| Variable | Description | Default |
|----------|-------------|---------|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector URL | `https://grpc.public.prd.shared.perforce.com` |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc`, `http/protobuf`, or `http/json` | `grpc` |

gRPC example (typical local collector on port 4317):

```json
"env": {
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc"
}
```

HTTP example (typical local collector on port 4318):

```json
"env": {
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318",
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf"
}
```

### What gets reported

Each MCP tool call creates:

- **Traces** — one span per call with the tool name, action, MCP client name and version, session ID, and error type when applicable.
- **Metrics** — `mcp.tool.calls` (count) and `mcp.tool.duration` (seconds), broken down by tool and action.

Security tokens and other credentials are not included in telemetry data.

If your MCP client sends `traceparent` or `tracestate` in the request metadata, Perfecto MCP links its spans to that parent trace.

---

Expand Down
12 changes: 12 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Security

## Reporting a vulnerability

If you believe you've found a security vulnerability, please report it responsibly:

- **Preferred:** [Report via GitHub Security Advisories](https://github.com/PerfectoCode/perfecto-mcp/security/advisories) (private report).
- **Alternative:** Open a [GitHub Issue](https://github.com/PerfectoCode/perfecto-mcp/issues) and use a discrete description; we will move sensitive details to a private channel.

Please do not disclose security issues in public issues or discussions before we've had a chance to address them.

Thank you for helping keep Perfecto MCP and its users safe.
7 changes: 4 additions & 3 deletions config/perfecto.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
TOOLS_PREFIX: str = "perfecto"
WEBSITE: str = "https://github.com/PerfectoCore/perfecto-mcp/"
GITHUB: str = "https://github.com/PerfectoCore/perfecto-mcp"
SUPPORT_MESSAGE: str = "If you think this is a bug, please contact Perfecto support or report issue at https://github.com/PerfectoCore/perfecto-mcp/issues"
WEBSITE: str = "https://github.com/PerfectoCode/perfecto-mcp/"
GITHUB: str = "https://github.com/PerfectoCode/perfecto-mcp"
GITHUB_API_LATEST_RELEASE: str = "https://api.github.com/repos/PerfectoCode/perfecto-mcp/releases/latest"
SUPPORT_MESSAGE: str = "If you think this is a bug, please contact Perfecto support or report issue at https://github.com/PerfectoCode/perfecto-mcp/issues"

SECURITY_TOKEN_FILE_ENV_NAME: str = "PERFECTO_SECURITY_TOKEN_FILE"
SECURITY_TOKEN_ENV_NAME: str = "PERFECTO_SECURITY_TOKEN"
Expand Down
140 changes: 140 additions & 0 deletions config/security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Security helpers for HTTP endpoint allowlisting and sensitive path detection."""

import re
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse


SENSITIVE_PATH_PREFIXES = (
"/etc/",
"/proc/",
"/sys/",
"/dev/",
"/boot/",
"/root/",
"/run/secrets/",
"/var/run/",
"/var/db/",
"/var/root/",
"/var/log/",
"/var/spool/",
"/private/etc/",
# macOS: /var symlinks to /private/var. Block sensitive subdirs; allow /private/var/folders/
"/private/var/run/",
"/private/var/db/",
"/private/var/root/",
"/private/var/log/",
"/private/var/spool/",
"/system/",
"/library/keychains/",
"/windows/",
"/program files/",
"/program files (x86)/",
"/programdata/",
)

SENSITIVE_PATH_CONTAINS = (
"/.ssh/",
"/.aws/",
"/.azure/",
"/.gnupg/",
"/.kube/",
"/.docker/",
"/.terraform/",
"/.pulumi/",
"/.config/gcloud/",
"/appdata/roaming/microsoft/credentials/",
"/appdata/roaming/gnupg/",
"/appdata/roaming/aws/",
)

SENSITIVE_FILE_NAMES = {
".env",
".netrc",
".git-credentials",
"kubeconfig",
".npmrc",
".pypirc",
"id_rsa",
"id_dsa",
"id_ecdsa",
"id_ed25519",
"known_hosts",
"authorized_keys",
"credentials",
"credentials.db",
"terraform.tfstate",
"terraform.tfstate.backup",
}

SENSITIVE_FILE_EXTENSIONS = {
".pem",
".key",
".p12",
".pfx",
".kdbx",
".tfstate",
".tfvars",
".ovpn",
}

# Domains allowed for unauthenticated http_request (help documentation fetches).
ALLOWED_HTTP_REQUEST_DOMAINS = (
"help.perfecto.io",
)


def normalize_path_for_security(file_path: str) -> str:
return file_path.replace("\\", "/").strip().lower()


def normalize_windows_drive_prefix(file_path: str) -> str:
if re.match(r"^[a-z]:/", file_path):
return file_path[2:]
return file_path


def detect_sensitive_upload_path_reason(file_path: str) -> Optional[str]:
# Denylist of sensitive local paths/files. Available for future upload flows;
# http_request allowlisting is the active control in this project today.
normalized_path = normalize_path_for_security(file_path)
normalized_without_drive = normalize_windows_drive_prefix(normalized_path)
base_name = Path(normalized_path).name

for prefix in SENSITIVE_PATH_PREFIXES:
if normalized_path.startswith(prefix) or normalized_without_drive.startswith(prefix):
return f"sensitive system path prefix '{prefix}'"

for sensitive_fragment in SENSITIVE_PATH_CONTAINS:
if sensitive_fragment in normalized_path:
return f"sensitive path segment '{sensitive_fragment}'"

if base_name in SENSITIVE_FILE_NAMES:
return f"sensitive file name '{base_name}'"

for sensitive_extension in SENSITIVE_FILE_EXTENSIONS:
if base_name.endswith(sensitive_extension):
return f"sensitive file extension '{sensitive_extension}'"

if base_name.startswith(".env."):
return "sensitive environment file pattern '.env.*'"

return None


def validate_http_request_endpoint(endpoint: str) -> Optional[str]:
parsed_url = urlparse(endpoint)

if parsed_url.scheme != "https":
return "Invalid endpoint scheme. Only https URLs are allowed."

if not parsed_url.hostname:
return "Invalid endpoint URL. Absolute URL with hostname is required."

host = parsed_url.hostname.lower()
if host not in ALLOWED_HTTP_REQUEST_DOMAINS:
allowed = ", ".join(ALLOWED_HTTP_REQUEST_DOMAINS)
return f"Endpoint host '{host}' is not allowed. Allowed hosts: {allowed}"

return None
21 changes: 12 additions & 9 deletions config/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ class PerfectoToken:
__slots__ = ("token", "cloud_name")

def __init__(self, token: str, cloud_name: str):
if not token or not isinstance(token, str):
raise PerfectoTokenError("Invalid security token format: expected non-empty string")
if cloud_name is not None and (not isinstance(cloud_name, str) or not cloud_name):
raise PerfectoTokenError("Invalid cloud name format: expected non-empty string")

self.token = token
self.cloud_name = cloud_name

Expand All @@ -34,20 +39,18 @@ def __init__(self, token: str, cloud_name: str):
def from_file(cls, path: Union[str, Path], cloud_name: str) -> "PerfectoToken":
p = Path(path)
if not p.exists() or not p.is_file():
raise PerfectoTokenError(f"directory or file does not exist: {p!r}")
raise PerfectoTokenError("Token file does not exist or is not a file")

try:
raw = p.read_text(encoding="utf-8")
except Exception as e:
raise PerfectoTokenError(f"Error reading/parsing file at {p!r}: {e}") from e
raise PerfectoTokenError(f"Error reading token file: {type(e).__name__}") from e

try:
token_val = raw
cloud_name_val = cloud_name
except KeyError as e:
raise PerfectoTokenError(f"missing field {e.args[0]!r} at {p!r}") from e
token_val = raw.strip()
if not token_val:
raise PerfectoTokenError("Token file is empty")

return cls(token=token_val, cloud_name=cloud_name_val)
return cls(token=token_val, cloud_name=cloud_name)

def __repr__(self):
return f"<PerfectoToken cloud_name={self.cloud_name!r} token={'*' * 8}>"
return "<PerfectoToken cloud_name=******** token=********>"
Loading
Loading