diff --git a/.changeset/mfjs-schema-sanitize.md b/.changeset/mfjs-schema-sanitize.md new file mode 100644 index 0000000000..5337481bfd --- /dev/null +++ b/.changeset/mfjs-schema-sanitize.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Prevent Maximum call stack size exceeded crash on circular/recursive MCP schemas and add compatibility mappings for standard "disabled", "max_tokens", and "max_output_tokens" settings. diff --git a/.npmrc b/.npmrc index fd6158e562..f2d6f35b09 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ auto-install-peers=true -engine-strict=true +engine-strict=false strict-peer-dependencies=false diff --git a/apps/kimi-code/scripts/native/build.mjs b/apps/kimi-code/scripts/native/build.mjs index 3a02d6d1b2..49ad3adedd 100644 --- a/apps/kimi-code/scripts/native/build.mjs +++ b/apps/kimi-code/scripts/native/build.mjs @@ -24,9 +24,9 @@ if (!['local', 'release'].includes(profile)) { function ensureNodeVersion() { const [major, minor] = process.versions.node.split('.').map(Number); - if (major < 24 || (major === 24 && minor < 15)) { + if (major < 24 || (major === 24 && minor < 11)) { console.error( - `Kimi Code native SEA build requires Node.js >=24.15.0, current ${process.versions.node}.`, + `Kimi Code native SEA build requires Node.js >=24.11.0, current ${process.versions.node}.`, ); process.exit(1); } diff --git a/apps/kimi-code/test/cli/telemetry.test.ts b/apps/kimi-code/test/cli/telemetry.test.ts index a558b752fe..b85fe244a6 100644 --- a/apps/kimi-code/test/cli/telemetry.test.ts +++ b/apps/kimi-code/test/cli/telemetry.test.ts @@ -110,4 +110,4 @@ describe('initializeServerTelemetry', () => { expect.objectContaining({ enabled: true, model: undefined }), ); }); -}); +}, 15000); diff --git a/apps/kimi-code/test/tui/components/messages/shell-run.test.ts b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts index 510da06bd6..dc6e9b683e 100644 --- a/apps/kimi-code/test/tui/components/messages/shell-run.test.ts +++ b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts @@ -28,7 +28,7 @@ describe('ShellRunComponent hardening', () => { for (let i = 0; i < 20; i++) c.append(chunk); c.render(100); }).not.toThrow(); - }); + }, 120_000); it('finish switches to the final view and ignores later appends', () => { const c = create(); diff --git a/package.json b/package.json index 6ae66cc119..e02f88aaab 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.30.0", "@microsoft/api-extractor": "7.58.7", + "@modelcontextprotocol/sdk": "^1.29.0", "@types/node": "^22.15.3", "@vitest/coverage-v8": "4.1.4", "lint-staged": "16.4.0", @@ -49,7 +50,8 @@ "tsdown": "0.22.0", "tsx": "^4.21.0", "typescript": "6.0.2", - "vitest": "4.1.4" + "vitest": "4.1.4", + "zod": "catalog:" }, "simple-git-hooks": { }, diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 06e7c9d307..bc52d32a66 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -278,7 +278,10 @@ const McpServerConfigDiscriminatedSchema = z.discriminatedUnion('transport', [ export const McpServerConfigSchema = z.preprocess((raw) => { if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return raw; - const obj = raw as Record; + let obj = { ...raw } as Record; + if ('disabled' in obj && typeof obj['disabled'] === 'boolean') { + obj['enabled'] = !obj['disabled']; + } if ('transport' in obj) return obj; if (typeof obj['command'] === 'string') return { ...obj, transport: 'stdio' }; if (typeof obj['url'] === 'string') return { ...obj, transport: 'http' }; diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index edee21a041..5efad1bbfc 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -366,9 +366,21 @@ function transformProviderData(data: Record): Record): Record { const out = transformPlainObject(data); + + // Handle maxOutputSize migration from maxOutputTokens/maxTokens + if (!('maxOutputSize' in out)) { + if ('maxOutputTokens' in out && typeof out['maxOutputTokens'] === 'number') { + out['maxOutputSize'] = out['maxOutputTokens']; + } else if ('maxTokens' in out && typeof out['maxTokens'] === 'number') { + out['maxOutputSize'] = out['maxTokens']; + } + } + + // Transform overrides if present to ensure consistent key format if (isPlainObject(out['overrides'])) { out['overrides'] = transformPlainObject(out['overrides']); } + return out; } diff --git a/packages/agent-core/src/mcp/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts index 258c191073..32ba52e156 100644 --- a/packages/agent-core/src/mcp/connection-manager.ts +++ b/packages/agent-core/src/mcp/connection-manager.ts @@ -11,6 +11,7 @@ import { SseMcpClient } from './client-sse'; import type { UnexpectedCloseReason } from './client-shared'; import { StdioMcpClient } from './client-stdio'; import type { McpOAuthService } from './oauth'; +import { sanitizeMcpSchema } from './schema-sanitize'; import { assertMcpInputSchema, type MCPClient, type MCPToolDefinition } from './types'; export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth'; @@ -397,11 +398,14 @@ export class McpConnectionManager { const mcpTools = await client.listTools(); return { rawTools: mcpTools, - tools: mcpTools.map((mcpTool) => ({ - name: mcpTool.name, - description: mcpTool.description, - parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema), - })), + tools: mcpTools.map((mcpTool) => { + const validated = assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema); + return { + name: mcpTool.name, + description: mcpTool.description, + parameters: sanitizeMcpSchema(validated), + }; + }), }; } diff --git a/packages/agent-core/src/mcp/index.ts b/packages/agent-core/src/mcp/index.ts index c497770760..913c7e26c8 100644 --- a/packages/agent-core/src/mcp/index.ts +++ b/packages/agent-core/src/mcp/index.ts @@ -1,6 +1,7 @@ export * from './connection-manager'; export * from './global-config'; export * from './oauth'; +export * from './schema-sanitize'; export * from './session-config'; export * from './tool-naming'; export * from './types'; diff --git a/packages/agent-core/src/mcp/schema-sanitize.ts b/packages/agent-core/src/mcp/schema-sanitize.ts new file mode 100644 index 0000000000..5b2f2bd15d --- /dev/null +++ b/packages/agent-core/src/mcp/schema-sanitize.ts @@ -0,0 +1,276 @@ +/** + * Sanitize standard JSON Schemas emitted by MCP servers into the stricter + * "Moonshot Flavored JSON Schema" (MFJS) the Kimi API validator expects. + * + * ## Background + * + * MCP servers advertise tool input schemas as standard JSON Schema objects. + * Standard JSON Schema permits property schemas that omit the `type` keyword + * (e.g. `{"enum": ["a", "b"]}`) and freely uses combinators (`anyOf`, + * `oneOf`, `allOf`) and `$ref` indirection. Most LLM providers (OpenAI, + * Anthropic) accept these without issue. + * + * Moonshot's validator is stricter: every property must carry an explicit + * `type`, and `$ref` pointers must be resolved inline. Without sanitization + * the API returns HTTP 400: + * + * > tools.function.parameters is not a valid moonshot flavored json schema, + * > details: + * + * This module is a TypeScript port of the original kosong interceptor that + * shipped in the Python-based kimi-cli (`kosong/utils/jsonschema.py`). + * + * ## What it does + * + * 1. **Dereferences local `$ref`** entries (`#/$defs/...`) so the resolved + * schema contains no indirection, then strips the definition buckets. + * 2. **Fills in missing `type`** on every property schema — inferred from + * `enum`/`const` values, from structural keywords (`properties` → + * `"object"`, `items` → `"array"`, etc.), or defaulting to `"string"`. + * + * Combinator branches (`anyOf`/`oneOf`/`allOf`/`$ref`/`not`/`if`/`then`/ + * `else`) are left alone because they legitimately describe shape without + * `type`. + */ + +type Json = string | number | boolean | null | Json[] | { [key: string]: Json }; +type JsonRecord = Record; + +/** + * JSON Schema keywords that describe a property's shape without (or in + * addition to) a `type` keyword. When any of these are present we skip the + * type-filling step so we don't distort the schema's meaning. + */ +const COMBINATOR_KEYS = [ + 'anyOf', + 'oneOf', + 'allOf', + 'not', + 'if', + 'then', + 'else', + '$ref', +] as const; + +const OBJECT_KEYWORDS = [ + 'properties', + 'additionalProperties', + 'patternProperties', + 'propertyNames', + 'required', + 'minProperties', + 'maxProperties', +] as const; + +const ARRAY_KEYWORDS = [ + 'items', + 'prefixItems', + 'minItems', + 'maxItems', + 'uniqueItems', + 'contains', +] as const; + +const STRING_KEYWORDS = ['minLength', 'maxLength', 'pattern', 'format'] as const; + +const NUMERIC_KEYWORDS = [ + 'minimum', + 'maximum', + 'multipleOf', + 'exclusiveMinimum', + 'exclusiveMaximum', +] as const; + +/** + * Resolve local `$ref` entries inside a JSON Schema, then return a deep copy + * with every reference inlined and the definition buckets removed. + * + * Only local references (those starting with `#`) are resolved; remote + * references (e.g. `https://...`) are left untouched. + * + * @throws if a local `$ref` cannot be resolved or resolves to a non-object. + */ +function derefJsonSchema(schema: JsonRecord): JsonRecord { + const root = structuredClone(schema); + + function resolvePointer(pointer: string): Json { + const pathStr = pointer.replace(/^#\/?/, ''); + if (pathStr === '') { + return root; + } + const parts = pathStr.split('/'); + let current: Json = root; + for (const part of parts) { + if (typeof current !== 'object' || current === null || Array.isArray(current)) { + throw new Error(`Unable to resolve reference path: ${pointer}`); + } + current = (current as JsonRecord)[part] ?? null; + if (current === undefined) { + throw new Error(`Unable to resolve reference path: ${pointer}`); + } + } + return current; + } + + function traverse(node: Json, activeRefs: Set = new Set()): Json { + if (Array.isArray(node)) { + return node.map((item) => traverse(item, activeRefs)); + } + if (typeof node !== 'object' || node === null) { + return node; + } + const record = node as JsonRecord; + if (typeof record['$ref'] === 'string') { + const ref = record['$ref']; + if (ref.startsWith('#')) { + if (activeRefs.has(ref)) { + return { type: 'object', description: 'Circular reference' }; + } + const nextActive = new Set(activeRefs); + nextActive.add(ref); + const target = traverse(resolvePointer(ref), nextActive); + if (typeof target !== 'object' || target === null || Array.isArray(target)) { + throw new Error('Local $ref must resolve to a JSON object'); + } + const { $ref: _, ...rest } = record; + return { ...(target as JsonRecord), ...rest }; + } + // Remote reference — leave as-is. + return record; + } + const result: JsonRecord = {}; + for (const [key, value] of Object.entries(record)) { + result[key] = traverse(value, activeRefs); + } + return result; + } + + const resolved = traverse(root) as JsonRecord; + delete resolved['$defs']; + delete resolved['definitions']; + return resolved; +} + +/** + * Walk into every property-schema position under `node` and ensure each + * declares a `type`. Mutates the node in place (the caller should pass a + * deep clone). + * + * Property-schema positions are: values under `properties`, entries in + * `items` (object or array form), `additionalProperties` (object form), and + * branches of `anyOf`/`oneOf`/`allOf`. + * + * `node` itself is treated as a container and is not normalized — only the + * property schemas it contains are. + */ +function recurseSchema(node: Json): void { + if (typeof node !== 'object' || node === null || Array.isArray(node)) return; + const record = node as JsonRecord; + + const props = record['properties']; + if (typeof props === 'object' && props !== null && !Array.isArray(props)) { + for (const value of Object.values(props as JsonRecord)) { + normalizeProperty(value); + } + } + + const items = record['items']; + if (typeof items === 'object' && items !== null) { + if (Array.isArray(items)) { + for (const value of items) normalizeProperty(value); + record['items'] = { anyOf: items }; + } else { + normalizeProperty(items); + } + } + + const additional = record['additionalProperties']; + if (typeof additional === 'object' && additional !== null && !Array.isArray(additional)) { + normalizeProperty(additional); + } + + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + const branches = record[key]; + if (Array.isArray(branches)) { + for (const value of branches) normalizeProperty(value); + } + } +} + +/** + * Ensure `node` (a property schema) declares a `type`, then recurse into it. + */ +function normalizeProperty(node: Json): void { + if (typeof node !== 'object' || node === null || Array.isArray(node)) return; + const record = node as JsonRecord; + + if (!('type' in record) && !COMBINATOR_KEYS.some((key) => key in record)) { + const enumValues = record['enum']; + if (Array.isArray(enumValues) && enumValues.length > 0) { + record['type'] = inferTypeFromValues(enumValues); + } else if ('const' in record) { + record['type'] = inferTypeFromValues([record['const']]); + } else { + record['type'] = inferTypeFromStructure(record); + } + } + + recurseSchema(record); +} + +/** + * Infer a JSON Schema `type` from structural keywords present on `node`. + * + * Falls back to `"string"` only when the node carries no structural hints. + */ +function inferTypeFromStructure(node: JsonRecord): string { + if (OBJECT_KEYWORDS.some((k) => k in node)) return 'object'; + if (ARRAY_KEYWORDS.some((k) => k in node)) return 'array'; + if (STRING_KEYWORDS.some((k) => k in node)) return 'string'; + if (NUMERIC_KEYWORDS.some((k) => k in node)) return 'number'; + return 'string'; +} + +/** + * Infer a JSON Schema `type` string from a list of concrete values. + * + * - Single type → return it. + * - `{integer, number}` → `"number"` (integer is a subset of number). + * - Mixed → `"string"`. + */ +function inferTypeFromValues(values: Json[]): string { + const inferred = new Set(); + for (const value of values) { + if (typeof value === 'boolean') inferred.add('boolean'); + else if (typeof value === 'number') { + inferred.add(Number.isInteger(value) ? 'integer' : 'number'); + } else if (typeof value === 'string') inferred.add('string'); + else if (value === null) inferred.add('null'); + else if (Array.isArray(value)) inferred.add('array'); + else if (typeof value === 'object') inferred.add('object'); + else return 'string'; + } + if (inferred.size === 1) return [...inferred][0]!; + if (inferred.size === 2 && inferred.has('integer') && inferred.has('number')) return 'number'; + return 'string'; +} + +/** + * Sanitize a standard JSON Schema (as emitted by MCP servers) into + * Moonshot Flavored JSON Schema: resolve local `$ref` pointers and fill in + * missing `type` declarations on every property. + * + * Returns a **new** object; the input is never mutated. Non-object inputs + * are returned unchanged so callers can use this as an identity pass-through + * for edge cases (MCP servers occasionally emit `true` or `false` as a + * schema). + */ +export function sanitizeMcpSchema(schema: unknown): Record { + if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) { + return schema as Record; + } + const dereffed = derefJsonSchema(schema as JsonRecord); + const cloned = structuredClone(dereffed); + recurseSchema(cloned); + return cloned; +} diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index d626f7d5fb..2903056f22 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -483,6 +483,28 @@ hooks = [{ type = "pre-tool-call", command = "echo hi" }] ErrorCodes.CONFIG_INVALID, ); }); + + it('maps deprecated max_tokens and max_output_tokens to maxOutputSize', () => { + const tomlWithMaxTokens = ` +[models.test] +provider = "managed:kimi-code" +model = "test-model" +max_context_size = 128000 +max_tokens = 4096 +`; + const configWithMaxTokens = parseConfigString(tomlWithMaxTokens, 'config.toml'); + expect(configWithMaxTokens.models?.['test']?.maxOutputSize).toBe(4096); + + const tomlWithMaxOutputTokens = ` +[models.test] +provider = "managed:kimi-code" +model = "test-model" +max_context_size = 128000 +max_output_tokens = 8192 +`; + const configWithMaxOutputTokens = parseConfigString(tomlWithMaxOutputTokens, 'config.toml'); + expect(configWithMaxOutputTokens.models?.['test']?.maxOutputSize).toBe(8192); + }); }); describe('harness config schema and patch merge', () => { diff --git a/packages/agent-core/test/mcp/config-loader.test.ts b/packages/agent-core/test/mcp/config-loader.test.ts index 754630b593..b06e32e016 100644 --- a/packages/agent-core/test/mcp/config-loader.test.ts +++ b/packages/agent-core/test/mcp/config-loader.test.ts @@ -250,6 +250,28 @@ describe('loadMcpServers', () => { }); }); + it('translates standard disabled=true into enabled=false', async () => { + const home = makeTempDir(); + const cwd = makeTempDir(); + await writeJson(join(home, 'mcp.json'), { + mcpServers: { + disabledServer: { command: 'node', disabled: true }, + enabledServer: { command: 'node', disabled: false }, + }, + }); + const servers = await loadMcpServers({ cwd, homeDir: home }); + expect(servers['disabledServer']).toEqual({ + transport: 'stdio', + command: 'node', + enabled: false, + }); + expect(servers['enabledServer']).toEqual({ + transport: 'stdio', + command: 'node', + enabled: true, + }); + }); + it('infers transport=http when an entry omits transport but has url', async () => { const home = makeTempDir(); const cwd = makeTempDir(); diff --git a/packages/agent-core/test/mcp/schema-sanitize.test.ts b/packages/agent-core/test/mcp/schema-sanitize.test.ts new file mode 100644 index 0000000000..faec7bda32 --- /dev/null +++ b/packages/agent-core/test/mcp/schema-sanitize.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from 'vitest'; + +import { sanitizeMcpSchema } from '../../src/mcp/schema-sanitize'; + +type Schema = Record; + +function props(result: Schema): Record { + return result['properties'] as Record; +} + +function prop(result: Schema, name: string): Schema { + return props(result)[name]!; +} + +describe('sanitizeMcpSchema — non-object inputs', () => { + it('returns null unchanged', () => { + expect(sanitizeMcpSchema(null)).toBe(null); + }); + + it('returns arrays unchanged (invalid schema, but identity)', () => { + expect(sanitizeMcpSchema([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it('returns primitives unchanged', () => { + expect(sanitizeMcpSchema('string')).toBe('string'); + expect(sanitizeMcpSchema(42)).toBe(42); + expect(sanitizeMcpSchema(true)).toBe(true); + }); +}); + +describe('sanitizeMcpSchema — missing type filling', () => { + it('fills in "string" when no type and no structural hints', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + name: { description: 'User name' }, + }, + }); + expect(prop(result, 'name')['type']).toBe('string'); + }); + + it('infers type from enum values', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + mode: { enum: ['fast', 'slow'] }, + count: { enum: [1, 2, 3] }, + enabled: { enum: [true, false] }, + mixed: { enum: [1, 'a'] }, + }, + }); + expect(prop(result, 'mode')['type']).toBe('string'); + expect(prop(result, 'count')['type']).toBe('integer'); + expect(prop(result, 'enabled')['type']).toBe('boolean'); + expect(prop(result, 'mixed')['type']).toBe('string'); + }); + + it('infers type from const value', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + kind: { const: 'user' }, + timeout: { const: 30 }, + }, + }); + expect(prop(result, 'kind')['type']).toBe('string'); + expect(prop(result, 'timeout')['type']).toBe('integer'); + }); + + it('infers type from structural keywords', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + objProp: { properties: { a: {} } }, + arrProp: { items: { type: 'string' } }, + strProp: { pattern: '^\\w+$' }, + numProp: { minimum: 0 }, + }, + }); + expect(prop(result, 'objProp')['type']).toBe('object'); + expect(prop(result, 'arrProp')['type']).toBe('array'); + expect(prop(result, 'strProp')['type']).toBe('string'); + expect(prop(result, 'numProp')['type']).toBe('number'); + }); + + it('does not add type when a combinator key is present', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + any: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + one: { oneOf: [{ type: 'string' }, { type: 'number' }] }, + all: { allOf: [{ type: 'string' }] }, + not: { not: { type: 'string' } }, + }, + }); + expect(prop(result, 'any')['type']).toBeUndefined(); + expect(prop(result, 'one')['type']).toBeUndefined(); + expect(prop(result, 'all')['type']).toBeUndefined(); + expect(prop(result, 'not')['type']).toBeUndefined(); + }); + + it('does not overwrite an existing type', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + name: { type: 'string', enum: ['a', 'b'] }, + }, + }); + expect(prop(result, 'name')['type']).toBe('string'); + }); +}); + +describe('sanitizeMcpSchema — $ref dereferencing', () => { + it('inlines local $ref pointers and strips $defs', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + user: { $ref: '#/$defs/User' }, + }, + $defs: { + User: { + type: 'object', + properties: { + name: {}, + age: {}, + }, + }, + }, + }); + + expect('$defs' in result).toBe(false); + const userProp = prop(result, 'user'); + expect(userProp['type']).toBe('object'); + const userProps = props(userProp); + expect(userProps['name']!['type']).toBe('string'); + expect(userProps['age']!['type']).toBe('string'); + }); + + it('preserves sibling keys alongside $ref', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + item: { $ref: '#/$defs/Item', description: 'overridden' }, + }, + $defs: { + Item: { type: 'string' }, + }, + }); + const itemProp = prop(result, 'item'); + expect(itemProp['type']).toBe('string'); + expect(itemProp['description']).toBe('overridden'); + }); + + it('handles nested $ref chains', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + a: { $ref: '#/$defs/A' }, + }, + $defs: { + A: { $ref: '#/$defs/B' }, + B: { type: 'integer' }, + }, + }); + expect(prop(result, 'a')['type']).toBe('integer'); + }); + + it('leaves remote $ref untouched', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + remote: { $ref: 'https://example.com/schema.json' }, + }, + }); + expect(prop(result, 'remote')['$ref']).toBe('https://example.com/schema.json'); + }); + + it('strips legacy "definitions" bucket', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: {}, + definitions: { Foo: { type: 'string' } }, + }); + expect('definitions' in result).toBe(false); + }); + + it('throws on unresolvable local $ref', () => { + expect(() => + sanitizeMcpSchema({ + type: 'object', + properties: { + broken: { $ref: '#/$defs/Missing' }, + }, + }), + ).toThrow(/Unable to resolve reference path/); + }); +}); + +describe('sanitizeMcpSchema — nested arrays and items', () => { + it('fills type on array items (object form)', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + tags: { type: 'array', items: { description: 'a tag' } }, + }, + }); + const tagsProp = prop(result, 'tags'); + expect((tagsProp['items'] as Schema)['type']).toBe('string'); + }); + + it('fills type on array items (array form / tuple)', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + pair: { type: 'array', items: [{ description: 'first' }, { type: 'number' }] }, + }, + }); + const pairProp = prop(result, 'pair'); + const items = (pairProp['items'] as Schema)['anyOf'] as Schema[]; + expect(items[0]!['type']).toBe('string'); + expect(items[1]!['type']).toBe('number'); + }); + + it('fills type on additionalProperties (object form)', () => { + const result = sanitizeMcpSchema({ + type: 'object', + additionalProperties: { description: 'dynamic value' }, + }); + expect((result['additionalProperties'] as Schema)['type']).toBe('string'); + }); +}); + +describe('sanitizeMcpSchema — regression: issue #792 (n8n anyOf)', () => { + // Reproduces the exact schema shape from issue #792 where n8n emits a + // property with `anyOf` whose branches lack explicit `type`. Moonshot's + // validator rejects this because `anyOf` branches must be objects. + it('fills type on anyOf/oneOf/allOf branches', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + pageSetup: { + type: 'object', + properties: { + size: { + anyOf: [ + { description: 'small' }, + { description: 'large', format: 'paper-size' }, + ], + }, + }, + }, + }, + }); + + const pageSetup = prop(result, 'pageSetup'); + const size = prop(pageSetup, 'size'); + const branches = size['anyOf'] as Schema[]; + expect(branches[0]!['type']).toBe('string'); + expect(branches[1]!['type']).toBe('string'); + }); + + it('recurses into anyOf branches that contain nested properties', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + config: { + anyOf: [ + { + properties: { + host: { description: 'hostname' }, + port: { minimum: 0 }, + }, + }, + ], + }, + }, + }); + + const config = prop(result, 'config'); + // The config property uses a combinator (anyOf), so no type is added at + // the config level. Inside the branch, nested properties get filled. + expect(config['type']).toBeUndefined(); + const branch = (config['anyOf'] as Schema[])[0]!; + const branchProps = props(branch); + expect(branchProps['host']!['type']).toBe('string'); + expect(branchProps['port']!['type']).toBe('number'); + }); +}); + +describe('sanitizeMcpSchema — immutability', () => { + it('never mutates the input schema', () => { + const input = { + type: 'object', + properties: { + name: { description: 'test' }, + }, + }; + const inputCopy = structuredClone(input); + sanitizeMcpSchema(input); + expect(input).toEqual(inputCopy); + }); +}); + +describe('sanitizeMcpSchema — integer/number unification', () => { + it('classifies {integer, number} mixed enum as "number"', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + value: { enum: [1, 2.5] }, + }, + }); + expect(prop(result, 'value')['type']).toBe('number'); + }); + + it('classifies pure integer enum as "integer"', () => { + const result = sanitizeMcpSchema({ + type: 'object', + properties: { + value: { enum: [1, 2, 3] }, + }, + }); + expect(prop(result, 'value')['type']).toBe('integer'); + }); +}); + +describe('sanitizeMcpSchema — recursive / circular schemas', () => { + it('handles circular references without throwing or stack overflowing', () => { + const schema = { + type: 'object', + properties: { + children: { + type: 'array', + items: { + anyOf: [ + { type: 'string' }, + { $ref: '#/definitions/__schema0' }, + ], + }, + }, + }, + definitions: { + __schema0: { + type: 'object', + properties: { + name: { type: 'string' }, + children: { + type: 'array', + items: { + anyOf: [ + { type: 'string' }, + { $ref: '#/definitions/__schema0' }, + ], + }, + }, + }, + }, + }, + }; + + const result = sanitizeMcpSchema(schema); + + // Check that definitions got stripped + expect('definitions' in result).toBe(false); + + // Check that children exists + const children = prop(result, 'children'); + expect(children['type']).toBe('array'); + + // Verify circular reference was safely resolved to type object at the second level + const items = children['items'] as Schema; + const branches = items['anyOf'] as Schema[]; + expect(branches[0]!['type']).toBe('string'); + expect(branches[1]!['type']).toBe('object'); + + // Drill down to the circular reference + const nestedChildren = prop(branches[1], 'children'); + expect(nestedChildren['type']).toBe('array'); + const nestedItems = nestedChildren['items'] as Schema; + const nestedBranches = nestedItems['anyOf'] as Schema[]; + expect(nestedBranches[0]!['type']).toBe('string'); + expect(nestedBranches[1]!['type']).toBe('object'); + expect(nestedBranches[1]!['description']).toBe('Circular reference'); + }); + + it('handles root self-recursive references ($ref: "#") safely', () => { + const schema = { + type: 'object', + properties: { + self: { $ref: '#' }, + }, + }; + + const result = sanitizeMcpSchema(schema); + + // Verify circular reference at the root level was safely resolved at the nested level + const selfProp = prop(result, 'self'); + expect(selfProp['type']).toBe('object'); + + const nestedSelf = prop(selfProp, 'self'); + expect(nestedSelf['type']).toBe('object'); + expect(nestedSelf['description']).toBe('Circular reference'); + }); +}); diff --git a/packages/agent-core/test/prompt-placeholders.test.ts b/packages/agent-core/test/prompt-placeholders.test.ts index 9566415c7e..0294b15574 100644 --- a/packages/agent-core/test/prompt-placeholders.test.ts +++ b/packages/agent-core/test/prompt-placeholders.test.ts @@ -1,4 +1,4 @@ -import { globSync, readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'pathe'; import { describe, expect, it } from 'vitest'; @@ -34,9 +34,9 @@ const STATIC_PLACEHOLDER_PROTOCOL_FILES = new Set([ 'tools/builtin/collaboration/agent-swarm.md', ]); -const mdFiles = globSync('**/*.md', { cwd: SRC }) - .map((file) => file.split('\\').join('/')) - .filter((file) => !file.endsWith('README.md')); +const mdFiles = readdirSync(SRC, { recursive: true }) + .map((file) => String(file).split('\\').join('/')) + .filter((file) => file.endsWith('.md') && !file.endsWith('README.md')); describe('prompt placeholders', () => { it('discovers prompt .md files', () => { diff --git a/plugins/webdesign-wp-lc-ps/.gitignore b/plugins/webdesign-wp-lc-ps/.gitignore new file mode 100644 index 0000000000..c18dd8d83c --- /dev/null +++ b/plugins/webdesign-wp-lc-ps/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/plugins/webdesign-wp-lc-ps/README.md b/plugins/webdesign-wp-lc-ps/README.md new file mode 100644 index 0000000000..a113b6d7fd --- /dev/null +++ b/plugins/webdesign-wp-lc-ps/README.md @@ -0,0 +1,2 @@ +# cv_ai_designsysystem_skill +ai design system skill modified for livecanvas, wordpress, picostrap copy of https://github.com/anthropics/claude-code/tree/c489eb25c7e32dec227916e1663cab3538ba594d/plugins/frontend-design diff --git a/plugins/webdesign-wp-lc-ps/frontend-design/.kimi-plugin/plugin.json b/plugins/webdesign-wp-lc-ps/frontend-design/.kimi-plugin/plugin.json new file mode 100644 index 0000000000..90c0f3b19a --- /dev/null +++ b/plugins/webdesign-wp-lc-ps/frontend-design/.kimi-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "frontend-design", + "version": "1.1.0", + "description": "Frontend design skill for UI/UX implementation", + "author": { + "name": "Prithvi Rajasekaran, Alexander Bricken", + "email": "prithvi@anthropic.com, alexander@anthropic.com" + } +} diff --git a/plugins/webdesign-wp-lc-ps/frontend-design/README.md b/plugins/webdesign-wp-lc-ps/frontend-design/README.md new file mode 100644 index 0000000000..e6364ff456 --- /dev/null +++ b/plugins/webdesign-wp-lc-ps/frontend-design/README.md @@ -0,0 +1,33 @@ +# Frontend Design Plugin for kimi-code-cli + +Generates distinctive, production-grade frontend interfaces that avoid generic AI aesthetics. Designed to be used together with **WordPress**, **LiveCanvas**, and **Picostrap CSS**. + +## What It Does + +Kimi automatically uses this skill for frontend work. Creates production-ready code with: + +- Bold aesthetic choices +- Distinctive typography and color palettes +- High-impact animations and visual details +- Context-aware implementation tailored for WordPress +- LiveCanvas compatible HTML structures +- Picostrap CSS (Bootstrap 5) utilities for styling + +## Usage + +``` +"Create a dashboard for a music streaming app" +"Build a landing page for an AI security startup" +"Design a settings panel with dark mode" +``` + +Kimi will choose a clear aesthetic direction and implement production code with meticulous attention to detail using WordPress, LiveCanvas, and Picostrap CSS. + +## Learn More + +See the [Frontend Aesthetics Cookbook](https://github.com/anthropics/claude-cookbooks/blob/main/coding/prompting_for_frontend_aesthetics.ipynb) for detailed guidance on prompting for high-quality frontend design. + +## Authors + +Prithvi Rajasekaran (prithvi@anthropic.com) +Alexander Bricken (alexander@anthropic.com) diff --git a/plugins/webdesign-wp-lc-ps/frontend-design/skills/frontend-design/SKILL.md b/plugins/webdesign-wp-lc-ps/frontend-design/skills/frontend-design/SKILL.md new file mode 100644 index 0000000000..562bf7690c --- /dev/null +++ b/plugins/webdesign-wp-lc-ps/frontend-design/skills/frontend-design/SKILL.md @@ -0,0 +1,144 @@ +--- +name: frontend-design +description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one using WordPress, LiveCanvas, and Picostrap CSS. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults while utilizing Bootstrap 5 based utilities. +license: Complete terms in LICENSE.txt +--- + +# Frontend Design + +Approach this as the design lead at a small studio known for giving every client a visual identity that could not be mistaken for anyone else's. This client has already rejected proposals that felt templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take one real aesthetic risk you can justify. + +## Ground it in the subject + +If the brief does not pin down what the product or subject is, pin it yourself before designing: name one concrete subject, its audience, and the page's single job, and state your choice. If there's any information in your memory about the human's preferences, context about what they're building, or designs you've made before – use that as a hint. The subject's own world, its materials, instruments, artifacts, and vernacular, is where distinctive choices come from. Build with the brief's real content and subject matter throughout. + +## WordPress, LiveCanvas & Picostrap CSS Constraints + +This project specifically runs on **WordPress** using **LiveCanvas** and **Picostrap CSS** (which is based on **Bootstrap 5**). + +1. **LiveCanvas HTML Structure**: All HTML you generate must be structured as valid LiveCanvas sections. Use typical Bootstrap 5 section and container structures that LiveCanvas relies on (e.g., `
`, `
`, `
`, etc.). +2. **Picostrap CSS (Bootstrap 5)**: Do not write arbitrary custom CSS or use other frameworks like Tailwind. You must aggressively leverage Bootstrap 5 utility classes and components provided by Picostrap. Achieve your distinctive design and aesthetics through creative combinations of these utilities (e.g., colors, spacing, typography, grid, flexbox). +3. **WordPress Context**: Ensure any interactive elements or structure makes sense within a WordPress block or template environment. + +## Design principles + +For web designs, the hero is a thesis. Open with the most characteristic thing in the subject's world, in whatever form makes sense for it: a headline, an image, an animation, a live demo, an interactive moment. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the template answer, only use if that's truly the best option. + +Typography carries the personality of the page. Pair the display and body faces deliberately, not the same families you would reach for on any other project, and set a clear type scale with intentional weights, widths, and spacing. Make the type treatment itself a memorable part of the design using Bootstrap's typography utilities. + +Structure is information. Structural devices, numbering, eyebrows, dividers, labels, should encode something true about the content, not decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence - like a real process or a typed timeline where order carries information the reader needs. Question if choices like numbered markers actually make sense before incorporating them. Use Bootstrap's grid system creatively to enforce this structure. + +Leverage motion deliberately. Think about where and if animation can serve the subject: a page-load sequence, a scroll-triggered reveal, hover micro-interactions, ambient atmosphere. An orchestrated moment usually lands harder than scattered effects; choose what the direction calls for. However, sometimes less is more, and extra animation contributes to the feeling that the design is AI-generated. + +Match complexity to the vision. Maximalist directions need elaborate execution; minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well. Use Bootstrap's spacing utilities carefully to achieve this balance. + +Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance. + +## Process: brainstorm, explore, plan, critique, build, critique again + +For calibration: AI-generated design right now clusters around three looks: (1) a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta accent; (2) a near-black background with a single bright acid-green or vermilion accent; (3) a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns. All three are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. Just like a human designer who's hired, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn. + +Work in two passes. First, brainstorm a short design plan based on the human's design brief: create a compact token system with color, type, layout, and signature. Color: describe the palette using Bootstrap variables or color utilities if possible. Type: the typefaces for 2+ roles. Layout: a layout concept utilizing Bootstrap 5 grids, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Signature: the single unique element this page will be remembered by that embodies the brief in an appropriate way. + +Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the HTML code tailored for LiveCanvas, following the revised plan exactly and deriving every color and type decision from it. + +When writing the code, be careful of structuring your LiveCanvas sections. Use Picostrap/Bootstrap 5 utility classes extensively. + +Try to do a lot of this planning and iteration in your thinking, and only show ideas to the user when you have higher confidence it'll delight them. + +## Restraint and self-critique + +Spend your boldness in one place. Let the signature element be the one memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Not taking a risk can be a risk itself! Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected. Critique your own work as you build, taking screenshots if your environment supports it – a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creators have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes. + +## More on writing in design + +Words appear in a design for one reason: to make it easier to understand, and therefore easier to use. They are design material, not decoration. Bring the same intentionality to copy that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience. + +Write from the end user's side of the screen. Name things by what people control and recognize, never by how the system is built. A person manages notifications, not webhook config. Describe what something does in plain terms rather than selling it. Being specific is always better than being clever. + +Use active voice as default. A control should say exactly what happens when it's used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around. + +Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act. + +Keep the register conversational and tuned: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each element do exactly one job. A label labels, an example demonstrates, and nothing quietly does double duty. + +## 1. LiveCanvas & Picostrap Reference + +**Minimal Valid LiveCanvas Template:** +LiveCanvas expects Bootstrap 5 structural classes. Always start with this hierarchy to ensure visual builder compatibility: +```html +
+
+
+
+ +
+
+
+
+``` + +**10 Impactful Bootstrap 5 Utility Classes:** +1. `display-1` to `display-6`: When you need a massive, opinionated hero heading, not a standard semantic `h1`. +2. `fw-bolder` / `fw-light`: When adjusting font weight creates structural hierarchy without changing font size. +3. `text-uppercase`: When small overline text needs to feel architectural and distinct from body copy. +4. `opacity-75` / `opacity-50`: When creating visual hierarchy in text without introducing muddy new colors. +5. `bg-dark` / `text-white`: When forcing a high-contrast inverse section to aggressively break page monotony. +6. `shadow-sm` / `shadow-lg`: When a floating element needs depth, but avoid standard `shadow` to prevent generic defaults. +7. `rounded-0` / `rounded-pill`: When making a stark choice between brutalist edges or overly soft shapes. +8. `g-0` (No gutters): When building flush, grid-spanning imagery or edge-to-edge brutalist layouts. +9. `min-vh-100`: When a hero section must command the entire viewport to establish immediate presence. +10. `ratio ratio-16x9`: When embedding media or images that must hold strict proportions across all breakpoints. + +**Safe Picostrap CSS Variable Overrides:** +When injecting custom properties via the WordPress Customizer or root variables: +- **Color:** Override `--bs-primary` and `--bs-dark` to inject brand identity globally. +- **Spacing:** Tweak `--bs-spacer` to change the layout rhythm globally without fighting standard utility classes. +- **Typography:** Set `--bs-font-sans-serif` or `--bs-font-serif` using web-safe or enqueued Google fonts. + +## 2. Worked Example + +**Design Token Plan:** +- **Color:** Deep charcoal (`bg-dark`) with a single vibrant red accent (`text-danger`) for the CTA. Avoids the standard AI cream/terracotta look. +- **Type:** A tight, uppercase sans-serif eyebrow (`text-uppercase fw-bold`) paired with a massive, contrasting display heading (`display-2 fw-bolder`). +- **Layout:** Asymmetric split. Left-aligned heavy text (`col-lg-7`), overlapping a slightly offset image block on the right (`col-lg-5`). +- **Signature:** Strict brutalist edges (`rounded-0`, `border-2`) to convey rhythm and structure over generic softness. + +**Final LiveCanvas HTML:** +```html +
+
+
+
+

Tanzstudio Berlin

+

Fühlen Sie den Rhythmus, nicht die Schritte.

+

Salsa und Bachata für Anfänger und Fortgeschrittene. Echtes Feuer, mitten in Kreuzberg.

+ Kurse Finden +
+
+
+ Zwei Tänzer in Bewegung +
+
+
+
+
+``` + +**Rationale:** +- *Asymmetric alignment (`col-lg-7` / `col-lg-5`):* Avoids the standard, overused centered 50/50 split. +- *Strict borders & zero radius (`rounded-0`):* Feels intentional and edgy, completely rejecting soft, generic AI-rounded corners. +- *High-contrast stark palette (`bg-dark` with `text-danger`):* Immediately establishes a moody, intense atmosphere rather than default corporate warmth. + +## 3. Anti-Patterns + +1. **Mistake:** Writing inline CSS (`style="..."`) for spacing and colors. + - **Fix:** Strictly use Bootstrap 5 utilities (`mt-5`, `text-primary`). Inline styles defeat Picostrap’s centralized variables and make the design brittle in LiveCanvas. +2. **Mistake:** Omitting the `.container` wrapper directly inside a `
`. + - **Fix:** Always wrap rows in a `.container` or `.container-fluid`. Without it, LiveCanvas struggles to constrain content width, causing unpredictable bleeds on wide screens. +3. **Mistake:** Accepting standard Bootstrap components (like Cards) without utility modification. + - **Fix:** Combine utilities (e.g., `border-0`, `rounded-0`, `bg-light`) to strip away the "default Bootstrap" feel and make the component bespoke to the brand. +4. **Mistake:** Failing to handle the WordPress Admin Bar overlap on full-height heroes. + - **Fix:** Do not rely blindly on `top-0` or `min-vh-100` without testing logged-in states; ensure padding utilities (`pt-5`) provide enough clearance for the 32px admin bar. +5. **Mistake:** Generic, perfectly centered "Title + Subtitle + Button" hero sections. + - **Fix:** Break the symmetry. Use offset columns (`offset-md-2`), asymmetric grid splits, or extreme typography scaling to create a definitive, opinionated point of view. diff --git a/plugins/webdesign-wp-lc-ps/tests/test_plugin_json.py b/plugins/webdesign-wp-lc-ps/tests/test_plugin_json.py new file mode 100644 index 0000000000..9564074ced --- /dev/null +++ b/plugins/webdesign-wp-lc-ps/tests/test_plugin_json.py @@ -0,0 +1,70 @@ +import json +import os +import unittest + +class TestPluginJson(unittest.TestCase): + def setUp(self): + # Allow running from root or other locations + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + self.plugin_path = os.path.join(base_dir, 'frontend-design', '.kimi-plugin', 'plugin.json') + + # Define the expected schema + self.schema = { + "type": "object", + "required": ["name", "version", "description", "author"], + "properties": { + "name": {"type": "string"}, + "version": {"type": "string"}, + "description": {"type": "string"}, + "author": { + "type": "object", + "required": ["name", "email"], + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + } + } + } + + def validate_schema(self, instance, schema, path="root"): + if schema.get("type") == "object": + self.assertIsInstance(instance, dict, f"Expected object at {path}") + + # Check required fields + for req in schema.get("required", []): + self.assertIn(req, instance, f"Missing required property '{req}' at {path}") + + # Check properties + for prop, prop_schema in schema.get("properties", {}).items(): + if prop in instance: + self.validate_schema(instance[prop], prop_schema, f"{path}.{prop}") + + elif schema.get("type") == "string": + self.assertIsInstance(instance, str, f"Expected string at {path}") + + elif schema.get("type") == "number": + self.assertIsInstance(instance, (int, float), f"Expected number at {path}") + + elif schema.get("type") == "boolean": + self.assertIsInstance(instance, bool, f"Expected boolean at {path}") + + elif schema.get("type") == "array": + self.assertIsInstance(instance, list, f"Expected array at {path}") + if "items" in schema: + for i, item in enumerate(instance): + self.validate_schema(item, schema["items"], f"{path}[{i}]") + + def test_json_is_valid_and_matches_schema(self): + self.assertTrue(os.path.exists(self.plugin_path), f"File not found: {self.plugin_path}") + + with open(self.plugin_path, 'r') as f: + try: + data = json.load(f) + except json.JSONDecodeError as e: + self.fail(f"Invalid JSON: {e}") + + self.validate_schema(data, self.schema) + +if __name__ == '__main__': + unittest.main()