Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format

## [Unreleased]

## [1.1.1] - 2026-08-12

### Changed

- [#399](https://github.com/mohanagy/miftah/issues/399) Prepared the compatible v1.1.1 patch release for Claude Desktop tool-catalog compatibility. Publication remains gated on exact `development`-to-`main` promotion and protected OIDC trusted publishing, registry provenance, a fresh install, and package-signature verification.

### Fixed

- [#397](https://github.com/mohanagy/miftah/issues/397) Preserved valid JSON Schema objects when tool input names look credential-related, so Vercel and Firebase catalogs no longer fail MCP client validation after redaction. Distinct sensitive schema keys now receive stable collision-free aliases that remain consistent across definitions and dependent references. Client-visible schema-valued `true` is emitted as its equivalent `{}` form for Claude Desktop proxy compatibility, while `false` constraints, ordinary boolean values, configured-secret redaction, bearer redaction, and provider-token redaction remain unchanged.

## [1.1.0] - 2026-08-12

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Install Miftah, then choose the terminal wizard or the browser Console. Both use
### 1. Install the current release

```bash
npm install -g @lubab/miftah@1.1.0
npm install -g @lubab/miftah@1.1.1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
miftah version
```

Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This page is the compatibility source of truth for Miftah's downstream MCP server. It records protocol-era behavior separately from generated client-configuration support and from upstream MCP transport support. A generated snippet proves only that Miftah emitted the documented JSON shape; it does not prove that an untested host completed a protocol exchange.

- Miftah baseline: `1.1.0`
- Miftah baseline: `1.1.1`
- Locked MCP TypeScript packages: `@modelcontextprotocol/client`, `core`, `server`, `node`, and `server-legacy` `2.0.0`
- Evidence date: 2026-08-12
- Modern protocol era: `2026-07-28`
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 @@ -5,7 +5,7 @@ This is the compatibility source of truth for generated `miftah init` configurat
For downstream protocol eras and real packaged-host evidence, see [MCP protocol and client compatibility](mcp-compatibility.md). The tables below validate generated configuration shapes; they do not by themselves establish a runtime exchange with Claude Desktop, Claude Code, Cursor, or VS Code.

- Catalog version: `3`
- Miftah package version: `1.1.0`
- Miftah package version: `1.1.1`
- 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 docs/whats-new-in-0.5.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# What is in Miftah 0.5

Install `@lubab/miftah@1.1.0`, the current stable release, to use the guided setup and account-management capabilities introduced in 0.5 instead of assembling a multi-account configuration by hand:
Install `@lubab/miftah@1.1.1`, the current stable release, to use the guided setup and account-management capabilities introduced in 0.5 instead of assembling a multi-account configuration by hand:

```bash
npm install -g @lubab/miftah@1.1.0
npm install -g @lubab/miftah@1.1.1
miftah version
```

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": "1.1.0",
"version": "1.1.1",
"description": "Wrap any MCP. Use the right account without reconnecting.",
"keywords": [
"mcp",
Expand Down
202 changes: 199 additions & 3 deletions src/mcp/server/miftah-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,7 +1071,10 @@ export class MiftahServer {
? tools.map(stripMcpParameterHeaderAnnotations)
: tools
};
}
},
undefined,
undefined,
(result) => redactToolListResult(result, this.redactor)
);
});

Expand Down Expand Up @@ -3052,14 +3055,15 @@ export class MiftahServer {
},
operation: (audit: AuditScope) => Promise<Result>,
errorResult?: (error: MiftahError) => Result,
resultAudit?: (result: Result) => AuditScopeResult
resultAudit?: (result: Result) => AuditScopeResult,
resultRedactor?: (result: Result) => Result
): Promise<Result> {
const audit = this.auditTrail.beginOperation(input);
try {
await this.auditTrail.ensureWritable();
const result = await operation(audit);
await audit.finish(resultAudit?.(result) ?? { status: "success" });
return this.redactor.redact(result);
return resultRedactor === undefined ? this.redactor.redact(result) : resultRedactor(result);
} catch (error) {
if (error instanceof ApprovalInputRequiredSignal) {
if (!audit.isFinalized) {
Expand Down Expand Up @@ -3641,6 +3645,198 @@ function stripMcpParameterHeaderAnnotations(tool: Tool): Tool {
};
}

const jsonSchemaMapKeywords = new Set([
"$defs",
"definitions",
"dependentSchemas",
"patternProperties",
"properties"
]);
const jsonSchemaArrayKeywords = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
const jsonSchemaValueKeywords = new Set([
"additionalItems",
"additionalProperties",
"contains",
"contentSchema",
"else",
"if",
"items",
"not",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties"
]);

/** Redacts catalog metadata without mistaking JSON Schema property names for secret values. */
function redactToolListResult<Result extends { tools: Tool[] }>(
result: Result,
redactor: SecretRedactor
): Result {
const { tools, ...metadata } = result;
return {
...redactor.redact(metadata),
tools: tools.map((tool) => redactClientVisibleTool(tool, redactor))
} as Result;
}

function redactClientVisibleTool(tool: Tool, redactor: SecretRedactor): Tool {
const { inputSchema, outputSchema, ...metadata } = tool;
return {
...redactor.redact(metadata),
inputSchema: redactClientVisibleSchema(inputSchema, redactor),
...(outputSchema === undefined
? {}
: { outputSchema: redactClientVisibleSchema(outputSchema, redactor) })
};
}

/**
* Preserves schema structure while redacting known values from textual metadata.
* A boolean `true` in a schema position is normalized to its equivalent `{}`
* form for clients whose schema adapters require objects; `false` stays closed.
*/
function redactClientVisibleSchema<Schema>(schema: Schema, redactor: SecretRedactor): Schema {
const context = createSchemaRedactionContext(schema, redactor);
return redactClientVisibleSchemaValue(schema, context);
}

interface SchemaRedactionContext {
aliases: ReadonlyMap<string, string>;
replacements: readonly (readonly [string, string])[];
redactor: SecretRedactor;
}

function createSchemaRedactionContext(schema: unknown, redactor: SecretRedactor): SchemaRedactionContext {
const names: string[] = [];
collectSchemaObjectKeys(schema, names, new Set());
const unchangedNames = new Set(names.filter((name) => redactor.redact(name) === name));
const usedAliases = new Set(unchangedNames);
const aliases = new Map<string, string>();

for (const name of names) {
if (aliases.has(name)) continue;
const redactedName = redactor.redact(name);
if (redactedName === name) {
aliases.set(name, name);
continue;
}

let alias = redactedName;
for (let suffix = 2; usedAliases.has(alias); suffix += 1) {
alias = suffixSchemaRedactionAlias(redactedName, suffix);
}
aliases.set(name, alias);
usedAliases.add(alias);
}

return {
aliases,
replacements: [...aliases]
.filter(([name, alias]) => name !== alias)
.sort(([left], [right]) => right.length - left.length),
redactor
};
}

function collectSchemaObjectKeys(value: unknown, names: string[], seen: Set<string>): void {
if (Array.isArray(value)) {
for (const entry of value) collectSchemaObjectKeys(entry, names, seen);
return;
}
if (!isRecord(value)) return;
for (const [name, entry] of Object.entries(value)) {
if (!seen.has(name)) {
seen.add(name);
names.push(name);
}
collectSchemaObjectKeys(entry, names, seen);
}
}

function suffixSchemaRedactionAlias(alias: string, suffix: number): string {
return alias.endsWith("]") ? `${alias.slice(0, -1)}_${suffix}]` : `${alias}_${suffix}`;
}

function redactSchemaName(name: string, context: SchemaRedactionContext): string {
return context.aliases.get(name) ?? context.redactor.redact(name);
}

function redactSchemaString(value: string, context: SchemaRedactionContext): string {
let result = "";
for (let offset = 0; offset < value.length; ) {
const replacement = context.replacements.find(([name]) => value.startsWith(name, offset));
if (replacement) {
result += replacement[1];
offset += replacement[0].length;
} else {
result += value[offset];
offset += 1;
}
}
return context.redactor.redact(result);
}

function redactClientVisibleSchemaValue<Schema>(schema: Schema, context: SchemaRedactionContext): Schema {
if (schema === true) return {} as Schema;
if (schema === false || schema === null || typeof schema !== "object") {
return (typeof schema === "string" ? redactSchemaString(schema, context) : schema) as Schema;
}
if (Array.isArray(schema)) {
return schema.map((entry) => redactClientVisibleSchemaValue(entry, context)) as Schema;
}

return Object.fromEntries(
Object.entries(schema).map(([keyword, value]) => {
const redactedKeyword = redactSchemaName(keyword, context);
if (jsonSchemaMapKeywords.has(keyword) && isRecord(value)) {
return [
redactedKeyword,
Object.fromEntries(
Object.entries(value).map(([name, nestedSchema]) => [
redactSchemaName(name, context),
redactClientVisibleSchemaValue(nestedSchema, context)
])
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];
}
if (jsonSchemaArrayKeywords.has(keyword) && Array.isArray(value)) {
return [redactedKeyword, value.map((nestedSchema) => redactClientVisibleSchemaValue(nestedSchema, context))];
}
if (jsonSchemaValueKeywords.has(keyword)) {
return [redactedKeyword, redactClientVisibleSchemaValue(value, context)];
}
if (keyword === "dependencies" && isRecord(value)) {
return [
redactedKeyword,
Object.fromEntries(
Object.entries(value).map(([name, dependency]) => [
redactSchemaName(name, context),
Array.isArray(dependency)
? redactSchemaLiteral(dependency, context)
: redactClientVisibleSchemaValue(dependency, context)
])
)
];
}
return [redactedKeyword, redactSchemaLiteral(value, context)];
})
) as Schema;
}

/** Redacts strings and object keys inside non-schema keyword values without changing booleans. */
function redactSchemaLiteral<Value>(value: Value, context: SchemaRedactionContext): Value {
if (typeof value === "string") return redactSchemaString(value, context) as Value;
if (Array.isArray(value)) return value.map((entry) => redactSchemaLiteral(entry, context)) as Value;
if (value === null || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([name, entry]) => [
redactSchemaName(name, context),
redactSchemaLiteral(entry, context)
])
) as Value;
}

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;
Expand Down
17 changes: 8 additions & 9 deletions tests/authenticated-request-context-docs-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";

const libraryApiPath = fileURLToPath(new URL("../docs/library-api.md", import.meta.url));
const changelogPath = fileURLToPath(new URL("../CHANGELOG.md", import.meta.url));
const packageManifestPath = fileURLToPath(new URL("../package.json", import.meta.url));
const protocolReleaseVersion = "1.1.0";

describe("authenticated request-context documentation contract", () => {
it("documents the trusted host boundary and the no-fallback compatibility path", async () => {
Expand All @@ -19,19 +19,18 @@ describe("authenticated request-context documentation contract", () => {
expect(documentation).toContain("does not synthesize verified per-chat claims");
});

it("records the additive security boundary under the package release", async () => {
it("records the additive security boundary under the v1.1.0 protocol release", async () => {
const changelog = await readFile(changelogPath, "utf8");
const manifest = JSON.parse(await readFile(packageManifestPath, "utf8")) as { version: string };
const escapedVersion = manifest.version.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const escapedVersion = protocolReleaseVersion.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const heading = changelog.match(new RegExp(`^## \\[${escapedVersion}\\] - \\d{4}-\\d{2}-\\d{2}$`, "mu"));
expect(heading?.index).toBeTypeOf("number");
const releaseStart = heading?.index ?? 0;
const releaseEnd = changelog.indexOf("\n## ", releaseStart + (heading?.[0].length ?? 0));
const currentRelease = changelog.slice(releaseStart, releaseEnd < 0 ? undefined : releaseEnd);
const protocolRelease = changelog.slice(releaseStart, releaseEnd < 0 ? undefined : releaseEnd);

expect(currentRelease).toContain("[#376]");
expect(currentRelease).toContain("for modern stateless handling");
expect(currentRelease).toContain("never falls back to MCP `clientInfo`");
expect(currentRelease).toContain("embedding hosts supply them through the public server factory");
expect(protocolRelease).toContain("[#376]");
expect(protocolRelease).toContain("for modern stateless handling");
expect(protocolRelease).toContain("never falls back to MCP `clientInfo`");
expect(protocolRelease).toContain("embedding hosts supply them through the public server factory");
});
});
8 changes: 4 additions & 4 deletions tests/fixtures/fake-upstream-bundled.mjs

Large diffs are not rendered by default.

Loading