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

## [Unreleased]

### 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. 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
118 changes: 115 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,114 @@ 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 {
if (schema === true) return {} as Schema;
if (schema === false || schema === null || typeof schema !== "object") {
return (typeof schema === "string" ? redactor.redact(schema) : schema) as Schema;
}
if (Array.isArray(schema)) {
return schema.map((entry) => redactClientVisibleSchema(entry, redactor)) as Schema;
}

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

/** Redacts strings and object keys inside non-schema keyword values without changing booleans. */
function redactSchemaLiteral<Value>(value: Value, redactor: SecretRedactor): Value {
if (typeof value === "string") return redactor.redact(value) as Value;
if (Array.isArray(value)) return value.map((entry) => redactSchemaLiteral(entry, redactor)) as Value;
if (value === null || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([name, entry]) => [redactor.redact(name), redactSchemaLiteral(entry, redactor)])
) 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
22 changes: 11 additions & 11 deletions tests/fixtures/fake-upstream-bundled.mjs

Large diffs are not rendered by default.

60 changes: 60 additions & 0 deletions tests/fixtures/fake-upstream-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const hangOnStartReadyPath = process.env.TEST_HANG_ON_START_READY_PATH;
const shutdownDelayMs = Number(process.env.TEST_SHUTDOWN_DELAY_MS ?? "0");
const shutdownEndPath = process.env.TEST_SHUTDOWN_END_PATH;
const includeIdentityTool = process.env.TEST_INCLUDE_IDENTITY_TOOL === "true";
const includeSchemaCompatibilityTools = process.env.TEST_INCLUDE_SCHEMA_COMPAT_TOOLS === "true";
const oversizedIdentityResponseRepeat = Number(process.env.TEST_OVERSIZED_IDENTITY_RESPONSE_REPEAT ?? "0");
const oversizedIdentityLogin =
Number.isSafeInteger(oversizedIdentityResponseRepeat) && oversizedIdentityResponseRepeat > 0
Expand Down Expand Up @@ -377,6 +378,65 @@ server.setRequestHandler('tools/list', async (request) => {
}
]
: []),
...(includeSchemaCompatibilityTools && !secondPage
? [
{
name: "vercel_schema_fixture",
description: "Expose Vercel-compatible input schemas.",
inputSchema: {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: {
[process.env.API_TOKEN ?? "missing-schema-secret"]: { type: "string" },
tokens: {
type: "integer",
description: `Configured ${process.env.API_TOKEN}; Bearer not-a-real-bearer-value`
},
passwordProtection: {
type: "boolean",
default: true,
description: "Provider github_pat_11ABCDEF_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP"
}
},
$defs: {
[process.env.API_TOKEN ?? "missing-schema-secret"]: { type: "string" }
},
dependencies: {
[process.env.API_TOKEN ?? "missing-schema-secret"]: ["tokens"]
},
default: {
[process.env.API_TOKEN ?? "missing-schema-secret"]: "configured-key",
"Bearer not-a-real-bearer-value": "bearer-key",
github_pat_11ABCDEF_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP: "provider-key"
},
examples: [{ [process.env.API_TOKEN ?? "missing-schema-secret"]: "configured-key" }],
additionalProperties: false
}
},
{
name: "firebase_schema_fixture",
description: "Expose Firebase-compatible pagination schemas.",
inputSchema: {
type: "object",
properties: {
page_token: { type: "string" }
}
}
},
{
name: "stripe_schema_fixture",
description: "Expose Stripe-compatible open output schemas.",
inputSchema: { type: "object", properties: {} },
outputSchema: {
type: "object",
properties: {
archived: { type: "boolean", default: true }
},
additionalProperties: true
}
}
]
: []),
...(process.env.TEST_INCLUDE_MANAGEMENT_TOOL === "true"
? [
{
Expand Down
53 changes: 53 additions & 0 deletions tests/mcp-wrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3864,6 +3864,59 @@ describe("Miftah MCP wrapper", () => {
}
});

it("preserves valid secret-looking tool schemas and normalizes open boolean schemas", async () => {
const secret = "configured-schema-secret";
const config = validateConfig({
version: "1",
name: "accounts",
defaultProfile: "work",
upstream: { transport: "stdio", command: process.execPath, args: [fixture] },
profiles: {
work: {
env: {
TEST_ACCOUNT_NAME: "work",
TEST_INCLUDE_SCHEMA_COMPAT_TOOLS: "true",
API_TOKEN: secret
}
}
}
});
const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 });
const wrapper = new MiftahServer(config, new ProfileManager(config), manager);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "schema-compatibility-client", version: "1.0.0" });

try {
await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]);

const result = await client.listTools();
const vercel = result.tools.find((tool) => tool.name === "vercel_schema_fixture");
const firebase = result.tools.find((tool) => tool.name === "firebase_schema_fixture");
const stripe = result.tools.find((tool) => tool.name === "stripe_schema_fixture");

expect(vercel?.inputSchema.properties).toMatchObject({
"[REDACTED]": { type: "string" },
tokens: { type: "integer" },
passwordProtection: { type: "boolean", default: true }
});
expect(vercel?.inputSchema.additionalProperties).toBe(false);
expect(firebase?.inputSchema.properties).toMatchObject({ page_token: { type: "string" } });
expect(stripe?.outputSchema).toMatchObject({
properties: { archived: { type: "boolean", default: true } },
additionalProperties: {}
});

const serialized = JSON.stringify(result);
expect(serialized).not.toContain(secret);
expect(serialized).not.toContain("not-a-real-bearer-value");
expect(serialized).not.toContain("github_pat_11ABCDEF_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP");
expect(serialized).toContain("[REDACTED]");
} finally {
await client.close();
await wrapper.close();
}
});

it("refreshes the advertised tool schema after a profile switch", async () => {
const config = validateConfig({
version: "1",
Expand Down