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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. The format

### Added

- [#365](https://github.com/mohanagy/miftah/issues/365) Added the MCP 2026-07-28 gateway header and cache contract. Modern Streamable HTTP relies on SDK validation of `Mcp-Method` and `Mcp-Name` against JSON-RPC before request-scoped broker construction, publishes explicit private zero-TTL cache hints, and returns canonically ordered resource, resource-template, and prompt catalogs with deterministic proxied template routes.
- [#367](https://github.com/mohanagy/miftah/issues/367) Added MCP 2026-07-28 OAuth client registration hardening for native remote connections. Endpoint-first CLI and Console setup can persist a reviewed Client ID Metadata Document URL when the authorization server advertises CIMD, while the shared runtime presents that URL as the client identifier without a registration request. Dynamic Client Registration remains a documented, advertised-endpoint-only compatibility fallback through the 1.x line, with removal no earlier than 2.0.0 after compatibility evidence and a migration path. Native loopback clients now explicitly declare `application_type: native`.
- [#363](https://github.com/mohanagy/miftah/issues/363) Added MCP `2026-07-28` serving alongside the existing legacy initialized era. STDIO now negotiates both eras through the SDK v2 serving entry; Streamable HTTP classifies each request before routing it to a fresh request-scoped modern server or the existing bounded sessionful legacy host. Modern requests use `server/discover`, carry per-request metadata, omit `Mcp-Session-Id`, propagate cancellation to the selected upstream, and return explicit supported-version diagnostics. The new public `createMiftahServerFactory` lets a trusted embedding host attach the authenticated stateless profile-context boundary without exposing Miftah's broker, policy, audit, OAuth, routing, or lifecycle internals.
- [#377](https://github.com/mohanagy/miftah/issues/377) Added the opt-in production profile-context boundary for trusted modern stateless hosts. Short-lived authenticated-encrypted handles bind a named profile to the verified issuer, subject, audience, chat, deployment, and monotonic sealing-key epoch; deployment-wide revocation, exact expiry, removed-profile checks, bearer-free keyed audit correlation, audited rejection and replacement-before-revocation ordering, and fixed fail-closed errors apply on every request. Reserved tool or request metadata is stripped before audit argument capture and upstream forwarding, transition approvals are bound to the authenticated chat correlation, and modern discovery enforces identical client-visible tools across profiles and is independent of prior selection calls. The CLI host does not synthesize trusted chat claims; an embedding host enables the boundary through `createMiftahServerFactory`.
Expand All @@ -17,6 +18,7 @@ All notable changes to this project will be documented in this file. The format

### Security

- [#365](https://github.com/mohanagy/miftah/issues/365) Fails closed on missing, malformed, duplicated, mismatched, or incorrectly encoded modern routing headers before routing, policy, audit, or upstream execution. Because Miftah's low-level proxy cannot yet validate schema-declared parameter headers end to end, modern HTTP strips `x-mcp-header` declarations and rejects every `Mcp-Param-*` header with a fixed non-reflective response; legacy HTTP and STDIO retain their existing argument behavior. Positive-TTL sharing remains disabled so catalogs cannot cross principals or survive profile, policy, upstream, or configuration changes.
- [#367](https://github.com/mohanagy/miftah/issues/367) Bound client information, tokens, discovery, and callback completion to the exact configured authorization-server issuer. Missing, duplicated, or mismatched RFC 9207 `iss` callback values fail before code redemption; issuer changes require a fresh connection identity and authorization; fixed errors, callback pages, configuration, and audit output do not expose authorization codes, tokens, client secrets, or raw provider details.

## [1.0.0] - 2026-08-11
Expand Down
6 changes: 6 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ Only literal `127.0.0.1` and `::1` are loopback bind values. A hostname, `localh

Each accepted HTTP session owns a fresh Miftah runtime, profile manager, routing/approval/lock/lease state, and upstream manager. HTTP sessions always use in-memory `session` profile state, even when the base configuration opts into durable state, so one client's selection cannot persist into or alter another client's session.

Modern `2026-07-28` HTTP requests must carry `Mcp-Method` and, for named operations such as `tools/call`, `Mcp-Name`. The SDK validates each value, including encoded names, against the parsed JSON-RPC request before Miftah creates a request-scoped broker or reaches routing, policy, audit, or an upstream. Missing, malformed, duplicated, or mismatched values fail with a generic HTTP 400 JSON-RPC error. These mirrored headers are routing hints, never credentials or authorization evidence.

Miftah does not yet support schema-aware `Mcp-Param-*` forwarding. Its modern HTTP catalog removes `x-mcp-header` declarations so conforming clients do not advertise the extension, and any received parameter header fails with a fixed HTTP 400 response before broker execution. Header names and values are not reflected, audited, or sent upstream. STDIO and the legacy initialized HTTP path retain their existing JSON-RPC argument behavior.

Modern cacheable results use `ttlMs: 0` and `cacheScope: "private"`. This is deliberate: the request-scoped gateway has no positive-TTL cache keyed by authenticated principal, profile, policy, and configuration revision, so it never shares a catalog across principals or serves a stale catalog after those inputs change. Resource, resource-template, and prompt lists are canonically ordered. Proxied resource-template route identifiers are derived deterministically from the upstream name, exposed name, and already-redacted catalog-visible URI template; no sensitive original template is used as identifier input. Legacy responses do not receive the modern cache fields.

## Profile credential isolation

`profiles.<profile>.isolation` is an opt-in, POSIX-only filesystem boundary for a local STDIO target. Miftah derives a deterministic runtime tree from the canonical configuration file, profile, and upstream name; it never accepts an operator-selected runtime root. The tree contains `home`, `appdata`, `localappdata`, and `xdg/config`, `xdg/cache`, `xdg/data`, `xdg/state`, and `xdg/runtime`. On macOS and Linux, Miftah verifies owner control, uses restrictive `0700` directories and `0600` copied files where the platform supports those modes, and refuses a reused target without its matching Miftah marker.
Expand Down
2 changes: 2 additions & 0 deletions docs/library-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ For a custom HTTP host, pass the same factory to `createMcpHandler` from `@model

This matrix describes Miftah's tested serving boundary, not a promise that every optional feature added to any future MCP revision is implemented. The SDK v2 serving entry owns protocol-era negotiation; Miftah continues to own broker routing, policy, audit, OAuth, profile state, upstream lifecycle, and cancellation propagation.

For modern Streamable HTTP, the serving entry validates `Mcp-Method` and `Mcp-Name` against the parsed JSON-RPC request before constructing Miftah's per-request server. Miftah currently declines the optional `Mcp-Param-*` extension: its CLI-owned modern HTTP catalog strips `x-mcp-header` schema keywords and its ingress rejects parameter headers without reflecting or forwarding them. Modern cacheable results are explicitly private and immediately stale (`ttlMs: 0`); deterministic resource, resource-template, and prompt ordering makes repeated uncached catalogs stable. STDIO and legacy initialized HTTP behavior remain unchanged.

Confirmation-required tools, resource reads, prompt reads, and profile transitions return the MCP `input_required` result on the modern era. Miftah binds the continuation to the exact operation with bounded, integrity-protected, one-time state shared by the server factory, so a fresh request-scoped HTTP instance can safely finish the approval without retaining raw operation arguments. The SDK's legacy shim translates the same handler flow into form elicitation for initialized clients.

## Authenticated request context
Expand Down
4 changes: 3 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ Windows profile credential isolation fails closed before it creates or copies a

The server rejects duplicate or malformed `Host`, `Origin`, authorization, and MCP-session headers. It compares one `Authorization: Bearer` value to the resolved token in constant time, never places the token in a response or diagnostic, and does not use cookies. Browser requests are denied by default: an absent `Origin` is valid for MCP clients, but a supplied origin must exactly match an explicit `allowedOrigins` entry. Miftah does not emit permissive CORS headers.

Before it allocates a session, the host validates the exact `/mcp` endpoint, method, Host, Origin, authentication, JSON content type, body size, and one initialize request. Session capacity includes in-progress initializations and closing runtimes. Each session receives a distinct Miftah runtime, profile/session state, approval/lock/lease/routing state, and upstream manager. Idle expiry, DELETE, and graceful shutdown detach the session immediately and close its runtime, which closes retained upstream transports and their ordinary descendants; detached cleanup retains capacity until it succeeds, and a failure remains capacity-consuming and is reported. An HTTP reconnect reuses only its existing session ID; it cannot create or access another client's runtime.
Before it allocates a session, the host validates the exact `/mcp` endpoint, method, Host, Origin, authentication, JSON content type, body size, and one initialize request. For modern requests, the SDK additionally validates the required `Mcp-Method` and operation-specific `Mcp-Name` mirrors against the parsed JSON-RPC body before the request-scoped broker can route, authorize, meter, audit, or execute it. Miftah rejects the unsupported `Mcp-Param-*` extension with a fixed response and strips its schema keyword from modern HTTP catalogs, so parameter names and values cannot cross into policy, audit, diagnostics, or an upstream. Session capacity includes in-progress initializations and closing runtimes. Each session receives a distinct Miftah runtime, profile/session state, approval/lock/lease/routing state, and upstream manager. Idle expiry, DELETE, and graceful shutdown detach the session immediately and close its runtime, which closes retained upstream transports and their ordinary descendants; detached cleanup retains capacity until it succeeds, and a failure remains capacity-consuming and is reported. An HTTP reconnect reuses only its existing session ID; it cannot create or access another client's runtime.

Modern cacheable results are private with a zero TTL. Miftah deliberately enables no positive-TTL shared cache until a cache can be keyed by authenticated principal plus profile, policy, and configuration revision and invalidated atomically on every relevant change. This prevents catalog reuse across accounts and makes profile or policy changes visible on the next request.

The Console control plane is not this MCP HTTP server. `miftah dashboard` or the API-only `miftah console` command creates a separate listener with no `/mcp` route, MCP transport, upstream session registry, or MCP bearer. It requires the exact listener Host for all traffic. State-changing browser requests require the exact listener Origin, including scheme, host, and port, plus in-memory CSRF proof; authenticated `GET`/`HEAD` reads may omit Origin because normal same-origin browsers do not consistently send it. Its CSPRNG bootstrap code appears only in the launching terminal, expires after five minutes, and is consumed once; the resulting opaque HttpOnly same-site session and in-memory CSRF proof are bounded by idle and absolute lifetimes. Every durable mutation prepares a separate fail-closed owner-restricted Console audit journal first. Browser responses contain allowlisted metadata and redacted status only. See the [Console API contract](console-api.md).

Expand Down
33 changes: 33 additions & 0 deletions src/http/miftah-http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const decimalPattern = /^\d+$/u;
const requestTimeoutMs = 60_000;
const headersTimeoutMs = 10_000;
const connectionsCheckingIntervalMs = 5_000;
const mcpHeaderMismatchErrorCode = -32020;

type SessionRuntimeFactory = (configPath: string) => Promise<MiftahRuntime>;

Expand Down Expand Up @@ -139,6 +140,34 @@ function isInitializeRequest(value: unknown): value is { readonly jsonrpc: "2.0"
return message.jsonrpc === "2.0" && message.method === "initialize" && Object.hasOwn(message, "id");
}

function hasMcpParameterHeader(request: IncomingMessage): boolean {
for (let index = 0; index < request.rawHeaders.length; index += 2) {
if (request.rawHeaders[index]?.toLowerCase().startsWith("mcp-param-")) return true;
}
return false;
}

function echoableRequestId(body: unknown): string | number | null {
if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
const id = (body as Record<string, unknown>).id;
return typeof id === "string" || typeof id === "number" || id === null ? id : null;
}

function unsupportedMcpParameterHeader(body: unknown): HttpRequestError {
return new HttpRequestError(
400,
JSON.stringify({
jsonrpc: "2.0",
id: echoableRequestId(body),
error: {
code: mcpHeaderMismatchErrorCode,
message: "Bad Request: MCP parameter headers are not supported by this gateway."
}
}),
{ "content-type": "application/json; charset=utf-8" }
);
}

function writeResponse(response: ServerResponse, error: HttpRequestError): void {
if (response.headersSent || response.writableEnded) {
response.destroy();
Expand Down Expand Up @@ -259,6 +288,10 @@ class HttpServerHost implements MiftahHttpServer {
const body = request.method === "POST" ? await this.parsePostBody(request) : undefined;
const webRequest = await toWebRequest(request, body);
if (!await isLegacyRequest(webRequest, body)) {
// Miftah's low-level upstream proxy cannot validate x-mcp-header schemas
// before policy and audit. Reject the extension without reflecting names
// or values until schema-aware forwarding can be supported end to end.
if (hasMcpParameterHeader(request)) throw unsupportedMcpParameterHeader(body);
if (this.modernRequests >= this.settings.maxSessions) {
throw new HttpRequestError(429, "Too Many Requests");
}
Expand Down
61 changes: 53 additions & 8 deletions src/mcp/server/miftah-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,8 @@ export class MiftahServer {
private readonly runtimeConfigPath?: string,
private readonly modernProfileContext?: ModernProfileContextRuntimeOptions,
private readonly resourceSubscriptionsEnabled = true,
private readonly approvalContinuations = new ApprovalContinuationStore()
private readonly approvalContinuations = new ApprovalContinuationStore(),
private readonly stripMcpParameterHeaderAnnotations = false
) {
if (
modernProfileContext !== undefined &&
Expand Down Expand Up @@ -398,6 +399,14 @@ export class MiftahServer {
: {})
},
requestState: { verify: (state) => this.approvalContinuations.verify(state) },
cacheHints: {
"tools/list": { ttlMs: 0, cacheScope: "private" },
"prompts/list": { ttlMs: 0, cacheScope: "private" },
"resources/list": { ttlMs: 0, cacheScope: "private" },
"resources/templates/list": { ttlMs: 0, cacheScope: "private" },
"resources/read": { ttlMs: 0, cacheScope: "private" },
"server/discover": { ttlMs: 0, cacheScope: "private" }
},
instructions: [
"Miftah wraps an upstream MCP and routes requests through local credential profiles.",
...(this.resourcePromptProxy.available
Expand Down Expand Up @@ -1053,11 +1062,14 @@ export class MiftahServer {
: this.profileToolSnapshot(source.activeProfile, upstreamRequest.options)
);
audit.update({ profile });
const tools = [
...this.visibleManagementTools(),
...snapshot.getTools().map((tool) => this.profileContextTool(tool, true))
];
return {
tools: [
...this.visibleManagementTools(),
...snapshot.getTools().map((tool) => this.profileContextTool(tool, true))
]
tools: this.stripMcpParameterHeaderAnnotations
? tools.map(stripMcpParameterHeaderAnnotations)
: tools
};
}
);
Expand Down Expand Up @@ -3602,24 +3614,57 @@ function aggregateProgressOptions(
function redactDirectResourceList(result: ListResourcesResult): ListResourcesResult {
return {
...result,
resources: result.resources.map(redactDirectResource)
resources: result.resources.map(redactDirectResource).sort(compareResources)
};
}

function redactDirectResourceTemplateList(result: ListResourceTemplatesResult): ListResourceTemplatesResult {
return {
...result,
resourceTemplates: result.resourceTemplates.map(redactDirectResourceTemplate)
resourceTemplates: result.resourceTemplates.map(redactDirectResourceTemplate).sort(compareResourceTemplates)
};
}

function redactDirectPromptList(result: ListPromptsResult): ListPromptsResult {
return {
...result,
prompts: result.prompts.map(redactDirectPrompt)
prompts: result.prompts.map(redactDirectPrompt).sort(comparePrompts)
};
}

function stripMcpParameterHeaderAnnotations(tool: Tool): Tool {
return {
...tool,
inputSchema: stripJsonSchemaKeyword(tool.inputSchema, "x-mcp-header")
};
}

function stripJsonSchemaKeyword<T>(value: T, keyword: string): T {
if (Array.isArray(value)) return value.map((entry) => stripJsonSchemaKeyword(entry, keyword)) as T;
if (typeof value !== "object" || value === null) return value;
return Object.fromEntries(
Object.entries(value).flatMap(([name, entry]) =>
name === keyword ? [] : [[name, stripJsonSchemaKeyword(entry, keyword)]]
)
) as T;
}

function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}

function compareResources(left: Resource, right: Resource): number {
return compareText(left.uri, right.uri) || compareText(left.name, right.name);
}

function compareResourceTemplates(left: ResourceTemplateType, right: ResourceTemplateType): number {
return compareText(left.name, right.name) || compareText(left.uriTemplate, right.uriTemplate);
}

function comparePrompts(left: Prompt, right: Prompt): number {
return compareText(left.name, right.name);
}

function redactDirectReadResult(result: ReadResourceResult): ReadResourceResult {
return {
...result,
Expand Down
Loading