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

### Changed

- [#366](https://github.com/mohanagy/miftah/issues/366) Completed the capability-gated MRTR confirmation boundary with packaged modern-client evidence, authenticated profile-context binding, deterministic one-time replay rejection, explicit cancelled audit outcomes, request-scoped upstream cleanup, and documented safe fallbacks. The experimental Tasks extension remains unimplemented after evaluating startup, OAuth, diagnostics, audit export, and recovery; Miftah makes no Tasks interoperability claim.
- [#363](https://github.com/mohanagy/miftah/issues/363) Replaced the monolithic MCP TypeScript SDK v1 dependency with the stable v2 split packages and migrated runtime schemas to Zod 4. Runtime consumers receive only `client`, `core`, and `server`; the Node adapter and frozen legacy server remain build/test dependencies. Direct consumers of the old monolithic SDK deep imports must move to the corresponding split package. The CLI bundles the v2 Node adapter with patched `@hono/node-server` and Hono builds so a fresh Miftah install does not inherit the Node package's still-vulnerable 1.x adapter range; custom embedding hosts own their direct Node adapter version. Confirmation-required tools, resources, prompts, and profile transitions now use the v2 `input_required` flow with integrity-bound one-time continuation state across request-scoped modern HTTP instances, while the SDK legacy shim preserves form elicitation for initialized clients. Native OAuth callback completion now carries the authorization-server issuer required by the v2 provider contract; Miftah continues to validate and round-trip that issuer without exposing tokens or client secrets.

### Fixed

- [#384](https://github.com/mohanagy/miftah/issues/384) Made continuation tamper coverage deterministic by mutating a decoded signature byte and re-encoding it canonically, so every test input changes authenticated bytes rather than possibly changing only unused base64url padding bits.

### Security

- [#366](https://github.com/mohanagy/miftah/issues/366) Bound form-approval continuations to the separately keyed authenticated request-context correlation in addition to the exact source/selected profiles, upstream, operation, target, and arguments. Cross-chat, cross-principal, cross-profile, mismatched, expired, and replayed state fails closed without disclosing handles, state, input responses, or operation arguments.
- [#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.

Expand Down
2 changes: 2 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ The default `security.approvalMode` is `"human"`. Clients that advertise MCP **f

Approval lifecycle events record request, approval, denial, expiry, and consumption when audit logging is configured. They contain safe profile/upstream/operation metadata, expiry, and `approvalMechanism` (`"form"` or `"delegated-agent"`) only; they never contain an approval bearer or full operation arguments.

On the modern `2026-07-28` protocol, form-capable clients complete confirmation through `input_required` and retry the exact operation with `inputResponses` plus opaque one-time `requestState`. Miftah additionally binds that state to the authenticated profile-context correlation when a trusted embedding host enables stateless chat-scoped profiles. An input-required round is audited as `confirmation-required`, an aborted operation as `cancelled`, a rejected operation as `failure`, and a completed operation as `success`. See the [MRTR and Tasks decision](plans/2026-08-11-mrtr-tasks-decision.md) for the retry, cancellation, fallback, and Tasks boundaries.

Audit logging writes local JSONL when a path is configured. Every supported MCP request emits one terminal operation event with a request ID, per-process session ID, source/selected profiles, stable outcome/error code, duration, and any available upstream, routing, policy, and risk metadata; route previews and proxied operations add sanitized `routingEvidence` when a collector snapshot is available and canonical `routingMatcherEvidence` for a static matcher result or ambiguity. Wrapper and upstream lifecycle transitions emit separate event records. Arguments are excluded unless `includeArguments` is true, and all configured secret values are redacted before writing. Audit directories and files are created with owner-only permissions where the platform supports them.

Every newly written record carries writer-controlled `schemaVersion: 1`; an event cannot select or override that marker. Miftah continues to read existing unversioned local JSONL records as legacy input. Additive fields may be introduced to schema version 1, but an incompatible audit-record interpretation requires a later minor release, an **Unreleased** changelog entry, and a documented reader or export migration path. When integrity is enabled, the schema marker is inside the chained payload and is therefore tamper-evident.
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.

Modern form confirmations use the protocol's `input_required` retry flow and bind one-time continuation state to the exact operation and authenticated profile context. The installed-package contract exercises that round trip through the CLI-owned HTTP server. Tasks are intentionally not advertised or implemented; see the [MRTR and Tasks decision](plans/2026-08-11-mrtr-tasks-decision.md).

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.
Expand Down
67 changes: 67 additions & 0 deletions docs/plans/2026-08-11-mrtr-tasks-decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# MRTR and Tasks Decision

Status: Multi Round-Trip Requests are implemented for confirmation workflows. The Tasks extension remains unimplemented until the interoperability and durability gates below are met.

Issue: [#366](https://github.com/mohanagy/miftah/issues/366)

Specification sources: [MCP 2026-07-28 announcement](https://blog.modelcontextprotocol.io/posts/2026-07-28/), [SEP-2322 MRTR](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322), [SEP-2663 Tasks extension](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), [Tasks overview](https://modelcontextprotocol.io/extensions/tasks/overview), and the [experimental Tasks repository](https://github.com/modelcontextprotocol/ext-tasks)

## Decision

Miftah uses `input_required` for operations that are otherwise ready to run but need one exact human confirmation. This covers policy approval and account/profile transition confirmation. The response embeds a generic form request; the client returns `inputResponses` and the byte-exact opaque `requestState` on a fresh retry.

Missing setup data and recovery are not converted into MRTR in this release. Setup is a local CLI or Console workflow that may change configuration and credentials, while recovery currently returns bounded diagnostics and an explicit next action. Neither has one resumable MCP operation with a stable authorization boundary. Adding an interactive round merely to gather arbitrary configuration would widen the credential and mutation surface.

The `io.modelcontextprotocol/tasks` extension remains unimplemented. No evaluated Miftah operation currently clears all selection gates, and the extension is still explicitly experimental. Consequently there is no selected Miftah task and the requirement for a durable task identifier plus authenticated lookup is not applicable yet. Miftah makes no Tasks interoperability claim.

## MRTR security and lifecycle contract

- Form MRTR is enabled only when the client declares `elicitation.form`. The embedded request contains only a generic boolean `approved` field and no target arguments, profile-context handle, credential, or continuation secret.
- Continuation state is short-lived, integrity protected, bounded in memory, and bound to the exact source profile, selected profile, upstream, operation, target, normalized arguments, and authenticated request-context correlation when present.
- A mismatched operation, profile, handle, chat, or principal cannot consume another request's state. Accepted state is consumed atomically before upstream work and cannot be replayed.
- A declined, expired, malformed, missing, or already-consumed response fails closed. A rejected mismatch does not consume the legitimate caller's pending continuation.
- The approval check happens before acquiring the selected upstream. Cancelling later work propagates the request signal to that upstream, records `REQUEST_CANCELLED`, releases the request-scoped modern runtime, and does not create or change a profile lock or lease.
- Clients without the declared form capability receive `POLICY_CONFIRMATION_REQUIRED` (or the corresponding profile-confirmation code) and an actionable explanation. No bearer is disclosed in the default human mode. The existing explicitly configured `delegated-agent` fallback remains connection-bound and one-time for legacy automation.

## Audit outcome vocabulary

The audit journal distinguishes each workflow state without storing `requestState`, `inputResponses`, profile-context handles, or approval bearers:

| Workflow meaning | Audit representation |
| --- | --- |
| Incomplete and waiting for input | operation status `confirmation-required`, plus approval action `requested` |
| Cancelled by the caller | operation status `cancelled` with `REQUEST_CANCELLED` |
| Failed or rejected | operation status `failure` with a stable redacted error code |
| Completed | operation status `success`; an approved MRTR also records `approved` then `consumed` |

Each request round has its own operation record. A confirmation-required first round is therefore complete as a protocol exchange but incomplete as the requested business operation. Every round applies the normal operation-argument policy: arguments are omitted unless `audit.includeArguments` is enabled, and enabled arguments pass through the configured secret redaction before storage. Continuation state, input responses, profile-context handles, and approval bearers remain excluded in either mode. Dedicated approval records never store operation arguments.

## Tasks evaluation

| Candidate | Decision | Reason |
| --- | --- | --- |
| Upstream startup | Defer | Startup is lazy and bounded by configured timeouts. A task must not hide a failed child start or keep an orphan process after cancellation. |
| OAuth authorization | Defer | Browser authorization already uses an issuer-bound connection identity and an explicit loopback handoff. A task would require durable encrypted state, authenticated owner lookup, expiry, and a supported client resume flow. |
| Diagnostics and readiness | Defer | `doctor`, `test-profile`, and health checks are bounded status operations with explicit redacted results; converting them would add polling without a demonstrated latency need. |
| Audit export | Defer | Export is a local CLI snapshot to an explicit private path, not a remote MCP operation. Turning it into a task would introduce remote file ownership and download authorization questions. |
| Recovery | Defer | Current recovery returns stable error codes and concrete next commands. No single recovery action is both long-running and safe to resume automatically. |

Tasks may be reconsidered only when all of these gates are satisfied:

1. the extension and Miftah's SDK line expose a supported end-to-end server and client contract;
2. a supported real host demonstrates one concrete operation that routinely exceeds a normal request budget;
3. the task store has a durable opaque identifier, authenticated principal/chat/profile ownership, bounded expiry, encrypted sensitive state, idempotent creation, and atomic terminal transitions;
4. `tasks/get`, `tasks/update`, and `tasks/cancel` enforce the same ownership for task lookup and terminal result retrieval, `inputResponses` updates, and cancellation, and survive process or connection loss;
5. cancellation proves no profile lock, lease, OAuth handoff, process reservation, or upstream child is left behind;
6. packaged interoperability tests cover reconnect, duplicate creation, replay, expiry, cancellation, redaction, and audit outcomes.

## Executable evidence and claim boundary

- The packed-artifact contract starts the installed `miftah serve --transport http`, connects the supported MCP TypeScript client in the modern era, completes one form round trip, verifies one upstream mutation, and checks `requested`, `approved`, and `consumed` audit actions.
- The stateless profile-context runtime test alternates two authenticated chats against fresh request-scoped servers. A stolen continuation with the other chat's valid handle fails, the original chat succeeds once, and replay fails.
- The modern HTTP cancellation test observes one upstream cancellation notification, request-scoped upstream cleanup, unchanged `none`/`not-required` lock and lease state, and the explicit cancelled audit outcome.
- Source and package tests establish Miftah behavior with the supported TypeScript client. They do not establish Tasks compatibility or compatibility with an untested host.

## Stop rule

Do not implement or advertise Tasks because an operation is merely asynchronous or inconvenient. Stop unless a real supported client, a concrete long-running Miftah use case, authenticated durable ownership, cleanup semantics, and packaged reconnect evidence all exist together.
3 changes: 3 additions & 0 deletions src/approvals/approval-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface ApprovalBinding {
readonly profile: string;
readonly upstream: string;
readonly operation: string;
/** Internal keyed correlation for the authenticated request context; never exposed as approval metadata. */
readonly requestCorrelation?: string;
/** The actual target identifier, retained only inside the keyed binding digest. */
readonly name: string;
/** A safe target label suitable for management output and audit events. */
Expand Down Expand Up @@ -321,6 +323,7 @@ export class ApprovalStore {
profile: binding.profile,
upstream: binding.upstream,
operation: binding.operation,
requestCorrelation: binding.requestCorrelation,
name: binding.name,
arguments: binding.arguments
})
Expand Down
9 changes: 8 additions & 1 deletion src/audit/audit-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,14 @@ export interface AuditHealth {
}

export type AuditEventKind = "operation" | "lifecycle" | "approval" | "profile";
export type AuditStatus = "success" | "failure" | "blocked" | "denied" | "confirmation-required" | "ambiguous";
export type AuditStatus =
| "success"
| "failure"
| "cancelled"
| "blocked"
| "denied"
| "confirmation-required"
| "ambiguous";
export type AuditRoutingSource = "rule" | "matcher" | "active-profile" | "default-profile" | "setup-profile";
export type ApprovalAuditAction = "requested" | "approved" | "denied" | "expired" | "consumed";
export type ProfileAuditAction =
Expand Down
1 change: 1 addition & 0 deletions src/cli/exit-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const ERROR_EXIT_CODES = {
UPSTREAM_CALL_FAILED: CLI_EXIT_CODES.upstream,
UPSTREAM_HTTP_ERROR: CLI_EXIT_CODES.upstream,
UPSTREAM_PROTOCOL_ERROR: CLI_EXIT_CODES.upstream,
REQUEST_CANCELLED: CLI_EXIT_CODES.operation,
AUDIT_WRITE_FAILED: CLI_EXIT_CODES.operation,
UPSTREAM_SELECTION_AMBIGUOUS: CLI_EXIT_CODES.upstream,
ROUTING_AMBIGUOUS: CLI_EXIT_CODES.policy,
Expand Down
4 changes: 3 additions & 1 deletion src/mcp/server/miftah-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1947,9 +1947,10 @@ export class MiftahServer {
profile,
upstream: "profiles",
operation: `profiles/${action}`,
requestCorrelation: authenticated.auditCorrelation,
name: profile,
displayName: `profile '${profile}'`,
arguments: { profile, requestCorrelation: authenticated.auditCorrelation }
arguments: { profile }
},
context,
profileSwitchApprovalErrors
Expand Down Expand Up @@ -3085,6 +3086,7 @@ export class MiftahServer {

/** Maps a safe domain error to its terminal audit outcome without exposing diagnostic detail. */
private auditStatus(error: MiftahError): AuditStatus {
if (error.code === "REQUEST_CANCELLED") return "cancelled";
if (
error.code === "POLICY_BLOCKED" ||
error.code === "ROUTING_BLOCKED" ||
Expand Down
6 changes: 6 additions & 0 deletions src/mcp/server/operation-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ export class OperationPipeline {
profile,
upstream: target.upstreamName ?? "default",
operation: operation.operation,
...(operation.source.profileContextCorrelation === undefined
? {}
: { requestCorrelation: operation.source.profileContextCorrelation }),
name: target.name,
displayName: this.auditName(operation, target.name),
arguments: operation.args
Expand All @@ -214,6 +217,9 @@ export class OperationPipeline {
return this.options.redactor.redact(target.redact(await target.execute(session, operation.upstreamRequestOptions)));
} catch (error) {
if (error instanceof ApprovalInputRequiredSignal) throw error;
if (operation.upstreamRequestOptions?.signal?.aborted || operation.approvalContext?.signal.aborted) {
throw new MiftahError("REQUEST_CANCELLED", "REQUEST_CANCELLED: request was cancelled");
}
const safeError = this.toSafeError(error);
const matcherEvidence = matcherEvidenceFromError(safeError);
if (matcherEvidence !== undefined) {
Expand Down
1 change: 1 addition & 0 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export type MiftahErrorCode =
| "UPSTREAM_CALL_FAILED"
| "UPSTREAM_HTTP_ERROR"
| "UPSTREAM_PROTOCOL_ERROR"
| "REQUEST_CANCELLED"
| "AUDIT_WRITE_FAILED"
| "UPSTREAM_SELECTION_AMBIGUOUS"
| "ROUTING_AMBIGUOUS"
Expand Down
Loading