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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Profiles are named credential environments. Keep secret values outside JSON when
"upstream": {
"transport": "stdio",
"command": "docker",
"args": ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
"args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server:v1.5.0"]
},
"profiles": {
"work": {
Expand Down Expand Up @@ -100,6 +100,8 @@ Profiles are named credential environments. Keep secret values outside JSON when
}
```

The GitHub preset pins `ghcr.io/github/github-mcp-server:v1.5.0`. To upgrade safely, read the release notes first, update the tag in your config, run `miftah validate`, then smoke-test both profiles before rollout.

Claude can call `miftah_list_profiles`, `miftah_current_profile`, `miftah_use_profile`, `miftah_profile_info`, `miftah_health`, `miftah_validate_config`, `miftah_list_upstream_tools`, `miftah_restart_profile`, and `miftah_route_preview`. Upstream tools are exposed unchanged unless they collide with a reserved `miftah_` name.

For account bundles, define `upstreams` instead of `upstream`. Tools are exposed as `<upstream>__<tool>` (for example `github__search_issues`) and each profile can provide per-upstream environment or header overrides. See `examples/multi-upstream.miftah.json`.
Expand All @@ -124,7 +126,7 @@ Routing can use the active profile or rules matching tool arguments:
}
```

When several profiles match, Miftah refuses to guess. Use explicit profile switching for write and destructive actions. Local policies can deny risky tools or return a confirmation-needed result. Provider token scopes still matter: local policy cannot make a write-capable provider token read-only.
When several profiles match, Miftah refuses to guess. Use explicit profile switching for write and destructive actions. Local policies can deny risky tools or return a confirmation-needed result. Provider token scopes still matter: local policy cannot make a write-capable provider token read-only. Profiles that set a policy name must reference an existing entry in `policies`, while profiles with no `policy` field keep the default allow behavior.

## Secret handling

Expand Down
7 changes: 5 additions & 2 deletions docs/examples/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Use the generic wrapper to run GitHub MCP with separate work and personal tokens
"upstream": {
"transport": "stdio",
"command": "docker",
"args": ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
"args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server:v1.5.0"]
},
"profiles": {
"work": {
Expand All @@ -33,10 +33,13 @@ Use the generic wrapper to run GitHub MCP with separate work and personal tokens
},
"safe-write": {
"allowRisk": ["read", "write"],
"denyRisk": ["destructive"]
"denyRisk": ["destructive"],
"requireConfirmation": ["write"]
}
}
}
```

This config contains references only. Set the variables in the shell that launches Claude Desktop.

When upgrading the pinned image tag, review upstream release notes first, then run `miftah validate --config <file>` and test both profiles before adopting the new tag.
4 changes: 3 additions & 1 deletion examples/github.miftah.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
"run",
"-i",
"--rm",
"ghcr.io/github/github-mcp-server"
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:v1.5.0"
]
},
"profiles": {
Expand Down
2 changes: 1 addition & 1 deletion examples/multi-upstream.miftah.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"github": {
"transport": "stdio",
"command": "docker",
"args": ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
"args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server:v1.5.0"]
},
"sentry": {
"transport": "stdio",
Expand Down
108 changes: 89 additions & 19 deletions src/config/presets.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,14 @@
import type { MiftahConfig } from "./types.js";
import type { MiftahConfig, UpstreamConfig } from "./types.js";

export function presetConfig(name: string, preset = "generic"): MiftahConfig {
const upstream =
preset === "github"
? { transport: "stdio" as const, command: "docker", args: ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"] }
: preset === "sentry"
? { transport: "stdio" as const, command: "npx", args: ["-y", "@sentry/mcp-server"] }
: { transport: "stdio" as const, command: "npx", args: ["-y", "your-mcp-server"] };
/** Pinned GitHub MCP server image used by the GitHub preset. */
export const GITHUB_MCP_IMAGE = "ghcr.io/github/github-mcp-server:v1.5.0";

type SharedDefaults = Pick<MiftahConfig, "routing" | "security" | "process" | "audit" | "tooling">;
type PresetBuilder = (name: string) => MiftahConfig;

/** Builds fresh shared runtime defaults so generated configs never share mutable state. */
function buildSharedDefaults(): SharedDefaults {
return {
version: "1",
name,
description: `${name} wrapped by Miftah`,
defaultProfile: "default",
upstream,
profiles: {
default: {
description: "Default account",
env: {}
}
},
routing: { mode: "hybrid", fallback: "activeProfile", rules: [] },
security: {
allowPlaintextSecrets: false,
Expand All @@ -37,3 +27,83 @@ export function presetConfig(name: string, preset = "generic"): MiftahConfig {
tooling: { managementToolPrefix: "miftah_", collisionStrategy: "prefix-upstream" }
};
}

/** Builds the common single-profile shape used by package-based presets. */
function buildStandardPreset(name: string, upstream: UpstreamConfig): MiftahConfig {
return {
version: "1",
name,
description: `${name} wrapped by Miftah`,
defaultProfile: "default",
upstream,
profiles: {
default: {
description: "Default account",
env: {}
}
},
policies: undefined,
...buildSharedDefaults()
};
}

/** Builds the generic starter preset for an unspecified MCP package. */
function buildGenericPreset(name: string): MiftahConfig {
return buildStandardPreset(name, {
transport: "stdio",
command: "npx",
args: ["-y", "your-mcp-server"]
});
}

/** Builds the Sentry MCP package preset. */
function buildSentryPreset(name: string): MiftahConfig {
return buildStandardPreset(name, {
transport: "stdio",
command: "npx",
args: ["-y", "@sentry/mcp-server"]
});
}

/** Builds the multi-profile GitHub preset and its referenced policies. */
function buildGithubPreset(name: string): MiftahConfig {
return {
version: "1",
name,
description: "GitHub MCP wrapped by Miftah",
defaultProfile: "work",
upstream: {
transport: "stdio",
command: "docker",
args: ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", GITHUB_MCP_IMAGE]
},
profiles: {
work: {
description: "Work GitHub account",
env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_WORK_TOKEN}" },
policy: "safe-write"
},
personal: {
description: "Personal GitHub account",
env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PERSONAL_TOKEN}" },
policy: "readonly"
}
},
policies: {
readonly: { allowRisk: ["read"], denyRisk: ["write", "destructive"] },
"safe-write": { allowRisk: ["read", "write"], denyRisk: ["destructive"], requireConfirmation: ["write"] }
},
...buildSharedDefaults()
};
}

const presetBuilders = new Map<string, PresetBuilder>([
["generic", buildGenericPreset],
["sentry", buildSentryPreset],
["github", buildGithubPreset]
]);

/** Builds a named configuration preset, falling back to the generic template. */
export function presetConfig(name: string, preset = "generic"): MiftahConfig {
return (presetBuilders.get(preset) ?? buildGenericPreset)(name);
}
14 changes: 14 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const policySchema = z.object({
requireConfirmation: z.array(z.string()).optional()
});

/** Zod schema for validating the complete Miftah configuration format. */
export const miftahConfigSchema = z
.object({
version: z.literal("1"),
Expand Down Expand Up @@ -120,9 +121,22 @@ export const miftahConfigSchema = z
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaultProfile"],
params: { miftahCode: "DEFAULT_PROFILE_NOT_FOUND" },
message: `DEFAULT_PROFILE_NOT_FOUND: profile '${value.defaultProfile}' does not exist`
});
}
const policyNames = new Set(Object.keys(value.policies ?? {}));
for (const [profileName, profile] of Object.entries(value.profiles)) {
if (profile.policy !== undefined && !policyNames.has(profile.policy)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["profiles", profileName, "policy"],
params: { miftahCode: "POLICY_NOT_FOUND" },
message: `POLICY_NOT_FOUND: policy '${profile.policy}' does not exist`
});
}
}
});

/** Input accepted by {@link miftahConfigSchema} before validation. */
export type MiftahConfigInput = z.input<typeof miftahConfigSchema>;
22 changes: 18 additions & 4 deletions src/config/validate-config.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import { z } from "zod";
import { miftahConfigSchema } from "./schema.js";
import type { MiftahConfig } from "./types.js";
import { MiftahError } from "../utils/errors.js";
import { MiftahError, type MiftahErrorCode } from "../utils/errors.js";

/** Narrows custom Zod metadata to error codes emitted by config refinements. */
function isConfigIssueCode(value: unknown): value is MiftahErrorCode {
return value === "DEFAULT_PROFILE_NOT_FOUND" || value === "POLICY_NOT_FOUND";
}

/** Reads a trusted Miftah error code from a custom Zod issue, if present. */
function getIssueCode(issue: z.ZodIssue): MiftahErrorCode | undefined {
if (issue.code !== z.ZodIssueCode.custom) {
return undefined;
}
const code = issue.params?.miftahCode;
return isConfigIssueCode(code) ? code : undefined;
}

/** Validates unknown input and returns a normalized Miftah configuration. */
export function validateConfig(input: unknown): MiftahConfig {
const result = miftahConfigSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues
.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
.join("; ");
const code = message.includes("DEFAULT_PROFILE_NOT_FOUND")
? "DEFAULT_PROFILE_NOT_FOUND"
: "CONFIG_SCHEMA_INVALID";
const code = result.error.issues.map(getIssueCode).find((issueCode) => issueCode !== undefined) ?? "CONFIG_SCHEMA_INVALID";
throw new MiftahError(code, `${code}: ${message}`);
}
return result.data;
Expand Down
8 changes: 7 additions & 1 deletion src/policy/policy-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,30 @@ import type { PolicyConfig, RiskLevel } from "../config/types.js";
import type { PolicyDecision } from "./policy-types.js";
import { classifyRisk } from "./risk-classifier.js";

/** Matches a tool name against an anchored glob pattern where `*` spans any characters. */
function matchesPattern(value: string, pattern: string): boolean {
const regex = new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`);
return regex.test(value);
}

/** Escapes regular-expression metacharacters before a glob segment is compiled. */
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/** Evaluates tool calls against named policies and risk overrides. */
export class PolicyEngine {
/** Creates an engine from policy definitions and optional per-tool risk overrides. */
constructor(
private readonly policies: Record<string, PolicyConfig> = {},
private readonly riskOverrides: Record<string, RiskLevel> = {}
) {}

/** Returns whether a tool call is allowed, denied, or requires confirmation. */
evaluate(policyName: string | undefined, toolName: string): PolicyDecision {
const risk = classifyRisk(toolName, this.riskOverrides);
const policy = policyName ? this.policies[policyName] : undefined;
if (policyName !== undefined && !Object.hasOwn(this.policies, policyName)) return { action: "deny", risk };
const policy = policyName !== undefined ? this.policies[policyName] : undefined;
if (!policy) return { action: "allow", risk };
if (policy.deny?.some((pattern) => matchesPattern(toolName, pattern))) {
return { action: "deny", risk };
Expand Down
44 changes: 40 additions & 4 deletions src/secrets/redact.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,53 @@
const secretKeyPattern = /(token|secret|password|api[_-]?key|auth|private|credential|authorization)/i;
const bearerPattern = /(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi;
const tokenPattern = /\b[A-Za-z0-9_-]{32,}\b/g;
const providerTokenPatterns = [
/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g
];
const camelCaseBoundaryPattern = /([a-z0-9])([A-Z])/g;
const nonAlphanumericPattern = /[^a-z0-9]+/;
const secretKeyTerms = new Set([
"token",
"tokens",
"secret",
"secrets",
"password",
"passwords",
"credential",
"credentials",
"authorization",
"auth",
"apikey",
"privatekey"
]);

/** Identifies structured-data keys whose values must be redacted in full. */
function isSecretKey(key: string): boolean {
const normalized = key.replace(camelCaseBoundaryPattern, "$1_$2").toLowerCase();
const parts = normalized.split(nonAlphanumericPattern).filter(Boolean);
if (parts.some((part) => secretKeyTerms.has(part))) {
return true;
}
return (parts.includes("api") && parts.includes("key")) || (parts.includes("private") && parts.includes("key"));
}

/** Redacts configured values and recognized credential formats from text. */
function redactString(value: string, secretValues: readonly string[]): string {
let result = value;
for (const secret of secretValues) {
if (secret.length > 0) {
result = result.split(secret).join("[REDACTED]");
}
}
return result.replace(bearerPattern, "$1[REDACTED]").replace(tokenPattern, "[REDACTED]");
result = result.replace(bearerPattern, "$1[REDACTED]");
for (const pattern of providerTokenPatterns) {
result = result.replace(pattern, "[REDACTED]");
}
return result;
}

/** Recursively redacts secrets while preserving the input's data shape. */
function redactValue(value: unknown, secretValues: readonly string[], key?: string): unknown {
if (key && secretKeyPattern.test(key)) {
if (key && isSecretKey(key)) {
return "[REDACTED]";
}
if (typeof value === "string") {
Expand All @@ -33,10 +67,12 @@ function redactValue(value: unknown, secretValues: readonly string[], key?: stri
return value;
}

/** Creates a reusable deep redactor for a fixed collection of secret values. */
export function createRedactor(secretValues: readonly string[] = []): <T>(value: T) => T {
return <T>(value: T) => redactValue(value, secretValues) as T;
}

/** Redacts secret values and secret-bearing keys from an arbitrary value. */
export function redactSecrets<T>(value: T, secretValues: readonly string[] = []): T {
return createRedactor(secretValues)(value);
}
4 changes: 4 additions & 0 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/** Stable error codes exposed to Miftah callers and command-line consumers. */
export type MiftahErrorCode =
| "CONFIG_NOT_FOUND"
| "CONFIG_INVALID_JSON"
| "CONFIG_SCHEMA_INVALID"
| "DEFAULT_PROFILE_NOT_FOUND"
| "POLICY_NOT_FOUND"
| "PROFILE_NOT_FOUND"
| "PROFILE_SWITCH_DISABLED"
| "SECRET_ENV_MISSING"
Expand All @@ -16,10 +18,12 @@ export type MiftahErrorCode =
| "POLICY_BLOCKED"
| "TOOL_COLLISION";

/** Error carrying a machine-readable Miftah code and optional structured context. */
export class MiftahError extends Error {
readonly code: MiftahErrorCode;
readonly details?: Record<string, unknown>;

/** Creates a Miftah error with a stable code, message, and optional diagnostic details. */
constructor(code: MiftahErrorCode, message: string, details?: Record<string, unknown>) {
super(message);
this.name = "MiftahError";
Expand Down
Loading