Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The UI5 [Model Context Protocol](https://modelcontextprotocol.io/) server offers
- `run_ui5_linter`: Integrates with [`@ui5/linter`](https://github.com/UI5/linter) to analyze and report issues in UI5 code.
- `get_integration_cards_guidelines`: Provides access to UI Integration Cards development best practices.
- `create_integration_card`: Scaffolds a new UI Integration Card.
- `run_manifest_validation`: Validates the manifest against the UI5 Manifest schema.

## Requirements

Expand Down
1 change: 1 addition & 0 deletions npm-shrinkwrap.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"@ui5/linter": "^1.20.2",
"@ui5/logger": "^4.0.2",
"@ui5/project": "^4.0.8",
"ajv": "^8.17.1",
"async-mutex": "^0.5.0",
"ejs": "^3.1.10",
"execa": "^9.6.0",
Expand Down
1 change: 1 addition & 0 deletions resources/integration_cards_guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
## 2. Validation
- **ALWAYS** ensure that `manifest.json` file is valid JSON.
- **ALWAYS** ensure that in `manifest.json` file the property `sap.app/type` is set to `"card"`.
- **ALWAYS** validate the `manifest.json` against the UI5 Manifest schema. You must do it using the `run_manifest_validation` tool.
- **ALWAYS** avoid using deprecated properties in `manifest.json` and elsewhere.
- **NEVER** treat Integration Cards' project as UI5 project, except for cards of type "Component".

Expand Down
3 changes: 3 additions & 0 deletions src/registerTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import registerGetGuidelinesTool from "./tools/get_guidelines/index.js";
import registerGetVersionInfoTool from "./tools/get_version_info/index.js";
import registerGetIntegrationCardsGuidelinesTool from "./tools/get_integration_cards_guidelines/index.js";
import registerCreateIntegrationCardTool from "./tools/create_integration_card/index.js";
import registerRunManifestValidationTool from "./tools/run_manifest_validation/index.js";

interface Options {
useStructuredContentInResponse: boolean;
Expand Down Expand Up @@ -51,6 +52,8 @@ export default function (server: McpServer, context: Context, options: Options)
registerGetIntegrationCardsGuidelinesTool(registerTool, context);

registerCreateIntegrationCardTool(registerTool, context);

registerRunManifestValidationTool(registerTool, context);
}

export function _processResponse({content, structuredContent}: CallToolResult, options: Options) {
Expand Down
35 changes: 35 additions & 0 deletions src/tools/run_manifest_validation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import runValidation from "./runValidation.js";
import {inputSchema, outputSchema} from "./schema.js";
import {getLogger} from "@ui5/logger";
import Context from "../../Context.js";
import {RegisterTool} from "../../registerTools.js";

const log = getLogger("tools:run_manifest_validation");

export default function registerTool(registerTool: RegisterTool, _context: Context) {
registerTool("run_manifest_validation", {
title: "Manifest Validation",
description:
"Validates UI5 manifest file." +
"After making changes, you should always run the validation again " +
"to verify that no new problems have been introduced.",
annotations: {
title: "Manifest Validation",
readOnlyHint: false,
},
inputSchema,
outputSchema,
}, async ({manifestPath}) => {
log.info(`Running manifest validation on ${manifestPath}...`);

const result = await runValidation(manifestPath);

return {
content: [{
type: "text",
text: JSON.stringify(result),
}],
structuredContent: result,
};
});
}
146 changes: 146 additions & 0 deletions src/tools/run_manifest_validation/runValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {fetchCdn} from "../../utils/cdnHelper.js";
import {RunSchemaValidationResult} from "./schema.js";
import Ajv2020, {AnySchemaObject} from "ajv/dist/2020.js";
import {readFile} from "fs/promises";
import {getLogger} from "@ui5/logger";
import {InvalidInputError} from "../../utils.js";
import {getManifestSchema} from "../../utils/ui5Manifest.js";
import {Mutex} from "async-mutex";
import {fileURLToPath} from "url";
import {isAbsolute} from "path";

const log = getLogger("tools:run_manifest_validation:runValidation");
const schemaCache = new Map<string, AnySchemaObject>();
const fetchSchemaMutex = new Mutex();

const AJV_SCHEMA_PATHS = {
draft06: fileURLToPath(import.meta.resolve("ajv/dist/refs/json-schema-draft-06.json")),
draft07: fileURLToPath(import.meta.resolve("ajv/dist/refs/json-schema-draft-07.json")),
} as const;

async function createUI5ManifestValidateFunction(ui5Schema: object) {
try {
const ajv = new Ajv2020.default({
allErrors: true, // Collect all errors, not just the first one
strict: false, // Allow additional properties that are not in schema
unicodeRegExp: false,
loadSchema: async (uri) => {
const release = await fetchSchemaMutex.acquire();

if (schemaCache.has(uri)) {
log.info(`Loading cached schema: ${uri}`);
return schemaCache.get(uri)!;
}

try {
log.info(`Loading external schema: ${uri}`);
const schema = await fetchCdn(uri) as AnySchemaObject;

// Special handling for Adaptive Card schema to fix unsupported "id" property
// According to the JSON Schema spec Draft 06 (used by Adaptive Card schema),
// "$id" should be used instead of "id"
// See https://github.com/microsoft/AdaptiveCards/issues/9274
if (uri.includes("adaptive-card.json") && typeof schema.id === "string") {
schema.$id = schema.id;
delete schema.id;
}

schemaCache.set(uri, schema);

return schema;
} catch (error) {
log.warn(`Failed to load external schema ${uri}:` +
`${error instanceof Error ? error.message : String(error)}`);

throw error;
} finally {
release();
}
},
});
const draft06MetaSchema = JSON.parse(
await readFile(AJV_SCHEMA_PATHS.draft06, "utf-8")
) as AnySchemaObject;
const draft07MetaSchema = JSON.parse(
await readFile(AJV_SCHEMA_PATHS.draft07, "utf-8")
) as AnySchemaObject;

ajv.addMetaSchema(draft06MetaSchema, "http://json-schema.org/draft-06/schema#");
ajv.addMetaSchema(draft07MetaSchema, "http://json-schema.org/draft-07/schema#");

const validate = await ajv.compileAsync(ui5Schema);

return validate;
} catch (error) {
throw new Error(`Failed to create UI5 manifest validate function: ` +
`${error instanceof Error ? error.message : String(error)}`);
}
}

async function readManifest(path: string) {
let content: string;
let json: object;

if (!isAbsolute(path)) {
throw new InvalidInputError(`The manifest path must be absolute: '${path}'`);
}

try {
content = await readFile(path, "utf-8");
} catch (error) {
throw new InvalidInputError(`Failed to read manifest file at ${path}: ` +
`${error instanceof Error ? error.message : String(error)}`);
}

try {
json = JSON.parse(content) as object;
} catch (error) {
throw new InvalidInputError(`Failed to parse manifest file at ${path} as JSON: ` +
`${error instanceof Error ? error.message : String(error)}`);
}

return json;
}

export default async function runValidation(manifestPath: string): Promise<RunSchemaValidationResult> {
log.info(`Starting manifest validation for file: ${manifestPath}`);

const manifest = await readManifest(manifestPath);
const manifestVersion = "latest";
log.info(`Using manifest version: ${manifestVersion}`);
const ui5ManifestSchema = await getManifestSchema(manifestVersion);
const validate = await createUI5ManifestValidateFunction(ui5ManifestSchema);
const isValid = validate(manifest);

if (isValid) {
log.info("Manifest validation successful");

return {
isValid: true,
errors: [],
};
}

// Map AJV errors to our schema format
const validationErrors = validate.errors ?? [];
const errors = validationErrors.map((error) => {
return {
keyword: error.keyword ?? "",
instancePath: error.instancePath ?? "",
schemaPath: error.schemaPath ?? "",
params: error.params ?? {},
propertyName: error.propertyName,
message: error.message,
schema: error.schema,
parentSchema: error.parentSchema,
data: error.data,
};
});

log.info(`Manifest validation failed with ${errors.length} error(s)`);

return {
isValid: false,
errors: errors,
};
}
40 changes: 40 additions & 0 deletions src/tools/run_manifest_validation/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {z} from "zod";

export const inputSchema = {
manifestPath: z.string()
.describe("Absolute path to the manifest file to validate."),
};

export const outputSchema = {
isValid: z.boolean()
.describe("Whether the manifest is valid according to the UI5 Manifest schema."),
errors: z.array(
z.object({
keyword: z.string()
.describe("Validation keyword."),
instancePath: z.string()
.describe("JSON Pointer to the location in the data instance (e.g., `/prop/1/subProp`)."),
schemaPath: z.string()
.describe("JSON Pointer to the location of the failing keyword in the schema."),
params: z.record(z.any())
.describe("An object with additional information about the error."),
propertyName: z.string()
.optional()
.describe("Set for errors in `propertyNames` keyword schema."),
message: z.string()
.optional()
.describe("The error message."),
schema: z.any()
.optional()
.describe("The value of the failing keyword in the schema."),
parentSchema: z.record(z.any())
.optional()
.describe("The schema containing the keyword."),
data: z.any()
.optional()
.describe("The data validated by the keyword."),
})
).describe("Array of validation error objects as returned by Ajv."),
};
const _outputSchemaObject = z.object(outputSchema);
export type RunSchemaValidationResult = z.infer<typeof _outputSchemaObject>;
62 changes: 58 additions & 4 deletions src/utils/ui5Manifest.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,73 @@
import {getLogger} from "@ui5/logger";
import {fetchCdn} from "./cdnHelper.js";
import {Mutex} from "async-mutex";

const log = getLogger("utils:ui5Manifest");

const LATEST_SCHEMA_URL = "https://raw.githubusercontent.com/SAP/ui5-manifest/main/schema.json";
const schemaCache = new Map<string, object>();
const fetchSchemaMutex = new Mutex();

let UI5ToManifestVersionMapping: Record<string, string> | null = null;
const MAPPING_URL = "https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json";
const ui5ToManifestVersionMappingMutex = new Mutex();

async function getUI5toManifestVersionMapping() {
const release = await ui5ToManifestVersionMappingMutex.acquire();

try {
if (UI5ToManifestVersionMapping) {
log.info("Loading cached UI5 to manifest version mapping");
return UI5ToManifestVersionMapping;
}

log.info("Fetching UI5 to manifest version mapping");
const mapping = await fetchCdn(MAPPING_URL);
log.info(`Fetched UI5 to manifest version mapping from ${MAPPING_URL}`);

async function getUI5toManifestVersionMap() {
const mapping = await fetchCdn(MAPPING_URL);
UI5ToManifestVersionMapping = mapping as Record<string, string>;

return mapping as Record<string, string>;
return UI5ToManifestVersionMapping;
} finally {
release();
}
}

async function fetchSchema(manifestVersion: string) {
const release = await fetchSchemaMutex.acquire();

try {
if (schemaCache.has(manifestVersion)) {
log.info(`Loading cached schema for manifest version: ${manifestVersion}`);
return schemaCache.get(manifestVersion)!;
}

log.info(`Fetching schema for manifest version: ${manifestVersion}`);
const schema = await fetchCdn(LATEST_SCHEMA_URL);
log.info(`Fetched UI5 manifest schema from ${LATEST_SCHEMA_URL}`);

schemaCache.set(manifestVersion, schema);

return schema;
} finally {
release();
}
}

export async function getLatestManifestVersion() {
const versionMap = await getUI5toManifestVersionMap();
const versionMap = await getUI5toManifestVersionMapping();

if (!versionMap.latest) {
throw new Error("Could not determine latest manifest version.");
}

return versionMap.latest;
}

export async function getManifestSchema(manifestVersion: string) {
if (manifestVersion !== "latest") {
throw new Error(`Only 'latest' manifest version is supported, but got '${manifestVersion}'.`);
}

return await fetchSchema(manifestVersion);
}
46 changes: 46 additions & 0 deletions test/fixtures/manifest_validation/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["_version", "sap.app"],
"properties": {
"_version": {
"type": "string"
},
"sap.app": {
"type": "object",
"required": ["id", "type", "applicationVersion"],
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"applicationVersion": {
"type": "object",
"required": ["version"],
"properties": {
"version": {
"type": "string"
}
}
},
"dataSources": {
"type": "object"
}
}
},
"sap.ui": {
"type": "object"
},
"sap.ui5": {
"type": "object"
}
}
}
Loading