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

## [Unreleased]

## [0.2.0] - 2026-07-14

### Added

- [#32](https://github.com/mohanagy/miftah/issues/32) Delivered MCP protocol conformance for resource templates, subscriptions, upstream list/update notifications, aggregate pagination, explicit capability/unsupported behavior, and request cancellation/progress forwarding across STDIO and Streamable HTTP upstreams.
Expand All @@ -22,6 +24,9 @@ All notable changes to this project will be documented in this file. The format
- [#31](https://github.com/mohanagy/miftah/issues/31) Delivered configurable audit-journal size/age rotation and safe retention, cross-process JSONL coordination, optional SHA-256-chain tamper evidence with first-break verification, and explicit redacted support export that omits stored arguments by default.
- [#34](https://github.com/mohanagy/miftah/issues/34) Delivered the versioned local plugin API for explicit secret providers and routing matchers: strict allowlisted configuration, preflight manifest/path validation, scrubbed bounded child hosts, canonical secret references and routing signals, redaction registration, request-level cancellation/timeout containment, package contracts, and reference documentation.
- [#38](https://github.com/mohanagy/miftah/issues/38) Added configuration format v2 with an explicit dry-run-first `migrate-config` command, exact exclusive backups for opted-in writes, historical v1 compatibility fixtures, documented compatibility/removal windows, and versioned public extension, CLI, management-tool, and audit contracts.
- Package metadata and a verified npm pack-content contract.
- Least-privilege CI and OIDC trusted-publishing workflows.
- Dependency update, contribution, vulnerability-reporting, and repository templates.

### Changed

Expand All @@ -33,17 +38,6 @@ All notable changes to this project will be documented in this file. The format

- Audit-journal local lock probing now treats interrupted lock-holder handoffs as unknown and retries the canonical candidate, preserving cross-process rotation exclusion instead of bypassing it.
- The package verifier now accepts both the list and keyed-object JSON formats emitted by supported npm `pack --json` versions while retaining the single-artifact and path allowlist checks.

## [0.1.1] - 2026-07-11

### Added

- Package metadata and a verified npm pack-content contract.
- Least-privilege CI and OIDC trusted-publishing workflows.
- Dependency update, contribution, vulnerability-reporting, and repository templates.

### Fixed

- [#1](https://github.com/mohanagy/miftah/issues/1) Policy lookup now fails closed and configuration rejects unknown profile policy references instead of allowing a policy fail-open.
- [#2](https://github.com/mohanagy/miftah/issues/2) Secret redaction preserves ordinary identifiers while still removing configured credentials from logs, errors, and discovery results.
- [#3](https://github.com/mohanagy/miftah/issues/3) The GitHub Docker preset injects profile credentials correctly and pins the upstream image tag.
Expand Down
2 changes: 1 addition & 1 deletion docs/presets-and-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
This is the compatibility source of truth for generated `miftah init` configurations and client snippets.

- Catalog version: `1`
- Miftah package version: `0.1.1`
- Miftah package version: `0.2.0`
- Last tested / validation boundary: the catalog builds strict Miftah configuration that `validateConfig` accepts. The docs contract test checks generated configuration only; it does **not** construct a runtime, start, authenticate to, or smoke-test external providers.

Miftah itself requires Node.js `>=20`. That does not establish an upstream server's Node requirement.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lubab/miftah",
"version": "0.1.1",
"version": "0.2.0",
"description": "Wrap any MCP. Use the right account without reconnecting.",
"keywords": [
"mcp",
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/fake-upstream.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const failListPromptsPath = process.env.TEST_FAIL_LIST_PROMPTS_PATH;
const crashOnCallToolPath = process.env.TEST_CRASH_ON_CALL_TOOL_PATH;
const crashAfterInitializedPath = process.env.TEST_CRASH_AFTER_INITIALIZED_PATH;
const startCountPath = process.env.TEST_START_COUNT_PATH;
const initializedPath = process.env.TEST_INITIALIZED_PATH;
const createItemCountPath = process.env.TEST_CREATE_ITEM_COUNT_PATH;
const callToolStartedPath = process.env.TEST_CALL_TOOL_STARTED_PATH;
const cancelledPath = process.env.TEST_CANCELLED_PATH;
Expand Down Expand Up @@ -215,6 +216,9 @@ const server = new Server(
{ capabilities: { tools: {}, resources: resourceSubscriptions ? { subscribe: true } : {}, prompts: {} } }
);
server.oninitialized = () => {
if (initializedPath) {
writeFileSync(initializedPath, "initialized");
}
if (crashAfterInitializedPath && existsSync(crashAfterInitializedPath)) {
void delay(0).then(() => process.exit(1));
}
Expand Down
14 changes: 10 additions & 4 deletions tests/identity-docs-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,23 @@ const digitGroupPattern = /\B(?=(\d{3})+(?!\d))/gu;
const beforeParsingPattern = /before parsing or normalization/iu;
const identityStatusPattern = /^\s*\|\s+"([^"]+)"/gmu;
const identityStatusFieldPattern = /^\s+(\w+)\??:/gmu;
const unreleasedIdentityPattern = /\[#21\][\s\S]*identity/iu;
const documentedIdentityPattern = /\[#21\][\s\S]*identity/iu;

function readRepositoryFile(path: string): string {
return readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
}

function unreleasedSection(changelog: string): string {
/** Returns pending changes, or the latest release notes once a release empties Unreleased. */
function documentedChangesSection(changelog: string): string {
const afterHeading = changelog.split(unreleasedHeadingPattern)[1];
if (afterHeading === undefined) throw new Error("CHANGELOG.md must contain an Unreleased section.");
const nextRelease = afterHeading.search(releaseHeadingPattern);
return nextRelease === -1 ? afterHeading : afterHeading.slice(0, nextRelease);
const unreleased = nextRelease === -1 ? afterHeading : afterHeading.slice(0, nextRelease);
if (unreleased.trim() !== "" || nextRelease === -1) return unreleased;

const currentRelease = afterHeading.slice(nextRelease);
const end = currentRelease.indexOf("\n## ", 1);
return end === -1 ? currentRelease : currentRelease.slice(0, end);
}

function identityVerificationSection(config: string): string {
Expand Down Expand Up @@ -152,6 +158,6 @@ describe("identity verification documentation contract", () => {
]) {
expect(security).toContain(claim);
}
expect(unreleasedSection(changelog)).toMatch(unreleasedIdentityPattern);
expect(documentedChangesSection(changelog)).toMatch(documentedIdentityPattern);
});
});
40 changes: 35 additions & 5 deletions tests/package-contract.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { spawn, spawnSync } from "node:child_process";
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { PassThrough, type Readable } from "node:stream";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -277,6 +277,24 @@ class TermIgnoringNpmProcess extends EventEmitter implements NpmProcess {
}
}

class DelayedNpmProcess extends EventEmitter implements NpmProcess {
readonly stdout = new PassThrough();
readonly stderr = new PassThrough();

constructor(delayMs: number) {
super();
setTimeout(() => this.emit("close", 0, null), delayMs);
}

kill(): boolean {
return true;
}
}

function fixtureLifecycleDiagnostic(startedPath: string, initializedPath: string): string {
return `Fixture lifecycle markers: source-loaded=${existsSync(startedPath)}, initialized=${existsSync(initializedPath)}`;
}

function quoteForWindowsCommand(value: string): string {
return `"${value.replace(/"/gu, '""')}"`;
}
Expand Down Expand Up @@ -474,10 +492,12 @@ describe("packed artifact contract", () => {
});
});

it("keeps the test worker responsive while an npm subprocess is running", async () => {
it("keeps the test worker responsive while a spawned npm process is pending", async () => {
let completed = false;
const child = new DelayedNpmProcess(100);
const spawnDelayedChild: NpmSpawner = () => child;
const running = Promise.resolve(
runNpm(["exec", "--", process.execPath, "--eval", "setTimeout(() => process.exit(0), 100)"])
runNpm(["exec", "--", process.execPath, "--eval", "process.exit(0)"], repositoryRoot, 1_000, spawnDelayedChild)
).finally(() => {
completed = true;
});
Expand Down Expand Up @@ -804,12 +824,22 @@ describe("packed artifact contract", () => {
profiles: { work: { env } },
process: { startupTimeoutMs: 1_000, shutdownTimeoutMs: 1_000 }
});
const healthyStartedPath = join(directory, "doctor-healthy-started");
const healthyInitializedPath = join(directory, "doctor-healthy-initialized");
const healthyConfigPath = await writeDoctorConfig(
"doctor-healthy.json",
doctorConfig("packed-doctor-healthy")
doctorConfig("packed-doctor-healthy", {
TEST_START_COUNT_PATH: healthyStartedPath,
TEST_INITIALIZED_PATH: healthyInitializedPath
})
);
const healthyDoctor = runInstalledBinary(binary, ["doctor", "--config", healthyConfigPath], directory);
expect(healthyDoctor.status, healthyDoctor.stderr || healthyDoctor.stdout).toBe(0);
expect(
healthyDoctor.status,
[healthyDoctor.stderr || healthyDoctor.stdout, fixtureLifecycleDiagnostic(healthyStartedPath, healthyInitializedPath)]
.filter(Boolean)
.join("\n")
).toBe(0);
expect(healthyDoctor.stderr).toBe("");
expect(healthyDoctor.stdout).toContain("Doctor: healthy");
expect(healthyDoctor.stdout).toContain("DOCTOR_CONFIGURATION");
Expand Down
16 changes: 11 additions & 5 deletions tests/preset-docs-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ function parseRepositoryJson(path: string): unknown {
return JSON.parse(readRepositoryFile(path)) as unknown;
}

function unreleasedSection(changelog: string): string {
/** Returns pending changes, or the latest release notes once a release empties Unreleased. */
function documentedChangesSection(changelog: string): string {
const afterHeading = changelog.split(/^## \[Unreleased\]\s*$/mu)[1];
if (afterHeading === undefined) {
throw new Error("CHANGELOG.md must contain an Unreleased section.");
}
const nextRelease = afterHeading.search(/^## \[/mu);
return nextRelease === -1 ? afterHeading : afterHeading.slice(0, nextRelease);
const unreleased = nextRelease === -1 ? afterHeading : afterHeading.slice(0, nextRelease);
if (unreleased.trim() !== "" || nextRelease === -1) return unreleased;

const currentRelease = afterHeading.slice(nextRelease);
const end = currentRelease.indexOf("\n## ", 1);
return end === -1 ? currentRelease : currentRelease.slice(0, end);
}

describe("preset documentation contract", () => {
Expand Down Expand Up @@ -98,8 +104,8 @@ describe("preset documentation contract", () => {
}
expect(compatibility).not.toContain("runtime construction");

const unreleased = unreleasedSection(changelog);
expect(unreleased).toMatch(/\[#19\][\s\S]*catalog[\s\S]*onboarding/iu);
expect(unreleased).not.toContain("runtime construction");
const documentedChanges = documentedChangesSection(changelog);
expect(documentedChanges).toMatch(/\[#19\][\s\S]*catalog[\s\S]*onboarding/iu);
expect(documentedChanges).not.toContain("runtime construction");
});
});
12 changes: 9 additions & 3 deletions tests/profile-leases-docs-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ function readRepositoryFile(path: string): string {
return readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
}

function unreleasedSection(changelog: string): string {
/** Returns pending changes, or the latest release notes once a release empties Unreleased. */
function documentedChangesSection(changelog: string): string {
const afterHeading = changelog.split(/^## \[Unreleased\]\s*$/mu)[1];
if (afterHeading === undefined) throw new Error("CHANGELOG.md must contain an Unreleased section.");
const nextRelease = afterHeading.search(/^## \[/mu);
return nextRelease === -1 ? afterHeading : afterHeading.slice(0, nextRelease);
const unreleased = nextRelease === -1 ? afterHeading : afterHeading.slice(0, nextRelease);
if (unreleased.trim() !== "" || nextRelease === -1) return unreleased;

const currentRelease = afterHeading.slice(nextRelease);
const end = currentRelease.indexOf("\n## ", 1);
return end === -1 ? currentRelease : currentRelease.slice(0, end);
}

describe("profile lease and lock documentation contract", () => {
Expand Down Expand Up @@ -39,6 +45,6 @@ describe("profile lease and lock documentation contract", () => {
expect(architecture).toContain("captured lease");
expect(cli).toContain("miftah_lock_profile");
expect(cli).toContain("miftah_unlock_profile");
expect(unreleasedSection(changelog)).toMatch(/\[#28\][\s\S]*profile/iu);
expect(documentedChangesSection(changelog)).toMatch(/\[#28\][\s\S]*profile/iu);
});
});
12 changes: 9 additions & 3 deletions tests/profile-runtime-isolation-docs-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ function readRepositoryFile(path: string): string {
return readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
}

function unreleasedSection(changelog: string): string {
/** Returns pending changes, or the latest release notes once a release empties Unreleased. */
function documentedChangesSection(changelog: string): string {
const afterHeading = changelog.split(/^## \[Unreleased\]\s*$/mu)[1];
if (afterHeading === undefined) throw new Error("CHANGELOG.md must contain an Unreleased section.");
const nextRelease = afterHeading.search(/^## \[/mu);
return nextRelease === -1 ? afterHeading : afterHeading.slice(0, nextRelease);
const unreleased = nextRelease === -1 ? afterHeading : afterHeading.slice(0, nextRelease);
if (unreleased.trim() !== "" || nextRelease === -1) return unreleased;

const currentRelease = afterHeading.slice(nextRelease);
const end = currentRelease.indexOf("\n## ", 1);
return end === -1 ? currentRelease : currentRelease.slice(0, end);
}

describe("profile credential isolation documentation contract", () => {
Expand Down Expand Up @@ -39,7 +45,7 @@ describe("profile credential isolation documentation contract", () => {
expect(architecture).toContain("ProfileRuntimeIsolation");
expect(architecture).toContain("--mount");
expect(architecture).toContain("macOS Podman isolation fail closed");
expect(unreleasedSection(changelog)).toMatch(/\[#29\][\s\S]*credential/iu);
expect(documentedChangesSection(changelog)).toMatch(/\[#29\][\s\S]*credential/iu);
});

it("states the native same-user and container boundaries without overclaiming containment", () => {
Expand Down
12 changes: 9 additions & 3 deletions tests/profile-state-docs-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,17 @@ function section(content: string, heading: RegExp): string {
return nextSection === -1 ? afterHeading : afterHeading.slice(0, nextSection);
}

function unreleasedSection(changelog: string): string {
/** Returns pending changes, or the latest release notes once a release empties Unreleased. */
function documentedChangesSection(changelog: string): string {
const afterHeading = changelog.split(unreleasedHeading)[1];
if (afterHeading === undefined) throw new Error("CHANGELOG.md must contain an Unreleased section.");
const nextRelease = afterHeading.search(releaseHeading);
return nextRelease === -1 ? afterHeading : afterHeading.slice(0, nextRelease);
const unreleased = nextRelease === -1 ? afterHeading : afterHeading.slice(0, nextRelease);
if (unreleased.trim() !== "" || nextRelease === -1) return unreleased;

const currentRelease = afterHeading.slice(nextRelease);
const end = currentRelease.indexOf("\n## ", 1);
return end === -1 ? currentRelease : currentRelease.slice(0, end);
}

describe("active profile state documentation contract", () => {
Expand Down Expand Up @@ -66,6 +72,6 @@ describe("active profile state documentation contract", () => {
}
expect(security).toContain("other MCP request data");
expect(cli).toContain("`selectionSource`, `selectedAt`, and `scope`");
expect(unreleasedSection(changelog)).toMatch(/\[#23\][\s\S]*active-profile persistence/iu);
expect(documentedChangesSection(changelog)).toMatch(/\[#23\][\s\S]*active-profile persistence/iu);
});
});
40 changes: 32 additions & 8 deletions tests/release-version.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

const releaseVersion = "0.1.1";
const releaseVersion = "0.2.0";

function readRepositoryFile(path: string): string {
return readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
Expand All @@ -21,13 +21,13 @@ function releaseNotes(changelog: string, version: string): string {
return changelog.slice(match.index, end < 0 ? undefined : end);
}

describe("v0.1.1 release artifacts", () => {
describe("v0.2.0 release artifacts", () => {
it.each([
"## [0.1.1] - 2026-7-11\n\n### Fixed\n",
"Release candidate: ## [0.1.1] - 2026-07-11\n\n### Fixed\n"
"## [0.2.0] - 2026-7-14\n\n### Fixed\n",
"Release candidate: ## [0.2.0] - 2026-07-14\n\n### Fixed\n"
])("requires a dated release heading at the start of a line", (changelog) => {
expect(() => releaseNotes(changelog, releaseVersion)).toThrow(
"Unable to find the 0.1.1 changelog entry."
"Unable to find the 0.2.0 changelog entry."
);
});

Expand Down Expand Up @@ -59,10 +59,34 @@ describe("v0.1.1 release artifacts", () => {
}
});

it("documents every hotfix while retaining the experimental package status", () => {
const notes = releaseNotes(readRepositoryFile("CHANGELOG.md"), releaseVersion);
it("documents the full release while retaining the experimental package status", () => {
const changelog = readRepositoryFile("CHANGELOG.md");
const notes = releaseNotes(changelog, releaseVersion);

for (const issue of ["#1", "#2", "#3", "#4", "#5"]) {
expect(changelog).not.toMatch(/^## \[0\.1\.1\] - /mu);
for (const issue of [
"#1",
"#2",
"#3",
"#4",
"#5",
"#16",
"#18",
"#19",
"#20",
"#21",
"#22",
"#23",
"#26",
"#27",
"#28",
"#29",
"#30",
"#31",
"#32",
"#34",
"#38"
]) {
expect(notes).toContain(issue);
}
expect(notes).toMatch(/policy.*fails?\s+closed|fails?\s+closed.*policy/iu);
Expand Down
Loading
Loading