@sparkerp/plugin-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/bundle/blocks.json +12229 -0
- package/bundle/catalog.json +32475 -0
- package/bundle/docs/applications/hcm/employee-profile.md +144 -0
- package/bundle/docs/concepts/artifact-types.md +80 -0
- package/bundle/docs/concepts/glossary.md +124 -0
- package/bundle/docs/concepts/overview.md +88 -0
- package/bundle/docs/concepts/page-data-flow.md +84 -0
- package/bundle/docs/concepts/plugin-lifecycle.md +81 -0
- package/bundle/docs/concepts/sdk-modes.md +71 -0
- package/bundle/docs/docs.json +109 -0
- package/bundle/docs/guides/add-a-cadence-job.md +134 -0
- package/bundle/docs/guides/add-a-compliance-job.md +139 -0
- package/bundle/docs/guides/add-a-create-edit-form.md +167 -0
- package/bundle/docs/guides/add-a-custom-block.md +225 -0
- package/bundle/docs/guides/add-a-data-provider.md +216 -0
- package/bundle/docs/guides/add-a-kpi.md +154 -0
- package/bundle/docs/guides/add-a-reminder-job.md +181 -0
- package/bundle/docs/guides/add-an-aggregation-job.md +153 -0
- package/bundle/docs/guides/add-an-approval-workflow.md +222 -0
- package/bundle/docs/guides/add-an-entity.md +170 -0
- package/bundle/docs/guides/add-business-rules.md +147 -0
- package/bundle/docs/guides/add-i18n.md +121 -0
- package/bundle/docs/guides/add-menus.md +149 -0
- package/bundle/docs/guides/build-a-code-plugin.md +141 -0
- package/bundle/docs/guides/build-a-page.md +213 -0
- package/bundle/docs/guides/build-a-polyglot-service-plugin.md +201 -0
- package/bundle/docs/guides/build-a-tenant-extension-service.md +149 -0
- package/bundle/docs/guides/create-a-plugin.md +172 -0
- package/bundle/docs/guides/expose-a-plugin-operation-as-an-ai-tool.md +114 -0
- package/bundle/docs/guides/index.md +53 -0
- package/bundle/docs/guides/publish-and-upgrade.md +152 -0
- package/bundle/docs/guides/responsive-plugin.md +117 -0
- package/bundle/docs/guides/seed-data-on-install.md +127 -0
- package/bundle/docs/guides/set-up-the-sdk.md +120 -0
- package/bundle/docs/guides/use-the-mcp-server.md +104 -0
- package/bundle/docs/guides/validate-and-test.md +119 -0
- package/bundle/docs/guides/wire-a-pages-data.md +153 -0
- package/bundle/docs/quickstart/index.md +118 -0
- package/bundle/docs/recipes/condition-based-compliance-flag.md +70 -0
- package/bundle/docs/recipes/cross-plugin-offboarding-fanout.md +68 -0
- package/bundle/docs/recipes/cross-row-aggregation.md +67 -0
- package/bundle/docs/recipes/entity-with-approval-workflow.md +72 -0
- package/bundle/docs/recipes/index.md +40 -0
- package/bundle/docs/recipes/install-lifecycle-data-seeding.md +76 -0
- package/bundle/docs/recipes/kpi-dashboard-page.md +92 -0
- package/bundle/docs/recipes/master-detail-page.md +84 -0
- package/bundle/docs/recipes/n-days-before-reminder.md +72 -0
- package/bundle/docs/recipes/per-row-document-generator.md +65 -0
- package/bundle/docs/recipes/related-record-lookup-field.md +74 -0
- package/bundle/docs/recipes/resolved-reference-grid-column.md +63 -0
- package/bundle/docs/recipes/scheduled-record-generation.md +68 -0
- package/bundle/docs/reference/action.md +18 -0
- package/bundle/docs/reference/block-definition.md +35 -0
- package/bundle/docs/reference/block-instance.md +33 -0
- package/bundle/docs/reference/composite-pack-manifest.md +33 -0
- package/bundle/docs/reference/connector-definition.md +36 -0
- package/bundle/docs/reference/data-service-definition.md +22 -0
- package/bundle/docs/reference/data-view-definition.md +26 -0
- package/bundle/docs/reference/entity-aggregation-config.md +38 -0
- package/bundle/docs/reference/entity-cadence-config.md +33 -0
- package/bundle/docs/reference/entity-compliance-config.md +37 -0
- package/bundle/docs/reference/entity-cross-plugin-action-config.md +37 -0
- package/bundle/docs/reference/entity-definition.md +18 -0
- package/bundle/docs/reference/entity-document-generator-config.md +35 -0
- package/bundle/docs/reference/entity-rule-definition.md +29 -0
- package/bundle/docs/reference/entity-status-date-sweep-config.md +27 -0
- package/bundle/docs/reference/form.md +35 -0
- package/bundle/docs/reference/index.md +37 -0
- package/bundle/docs/reference/menu.md +26 -0
- package/bundle/docs/reference/mobile-nav.md +22 -0
- package/bundle/docs/reference/page-template.md +29 -0
- package/bundle/docs/reference/page.md +44 -0
- package/bundle/docs/reference/plugin-manifest.md +54 -0
- package/bundle/docs/reference/plugin-seed-data.md +21 -0
- package/bundle/docs/reference/print-template-definition.md +22 -0
- package/bundle/docs/reference/provider-definition.md +22 -0
- package/bundle/docs/reference/report-definition.md +22 -0
- package/bundle/docs/reference/rule.md +30 -0
- package/bundle/docs/reference/workflow-definition.md +28 -0
- package/bundle/docs/troubleshooting/grid-shows-zero-rows.md +82 -0
- package/bundle/docs/troubleshooting/job-never-runs.md +89 -0
- package/bundle/docs/troubleshooting/known-gotchas.md +193 -0
- package/bundle/docs/troubleshooting/publishing-errors.md +119 -0
- package/bundle/docs/tutorial/01-create-the-plugin.md +157 -0
- package/bundle/docs/tutorial/02-category-entity-and-seed.md +94 -0
- package/bundle/docs/tutorial/03-equipment-entity.md +121 -0
- package/bundle/docs/tutorial/04-equipment-register-page.md +153 -0
- package/bundle/docs/tutorial/05-checkout-entity-and-page.md +116 -0
- package/bundle/docs/tutorial/06-checkout-approval-workflow.md +139 -0
- package/bundle/docs/tutorial/07-return-due-reminder-job.md +129 -0
- package/bundle/docs/tutorial/08-menus-i18n-publish.md +123 -0
- package/bundle/docs/tutorial/index.md +72 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-aggregation-configs.json +18 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cadence-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-compliance-configs.json +74 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cross-plugin-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-document-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-sweep-configs.json +260 -0
- package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/entities/hcm_salary_planning_entry.json +41 -0
- package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/page/salary-planning.json +1 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/page/promotions.json +10944 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.movement.json +44 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.promotion-acknowledge.json +19 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.resignation.json +29 -0
- package/bundle/examples/example-patterns.mjs +94 -0
- package/bundle/manifest.json +14 -0
- package/bundle/schemas/action.schema.json +102 -0
- package/bundle/schemas/block-definition.schema.json +222 -0
- package/bundle/schemas/block-instance.schema.json +139 -0
- package/bundle/schemas/composite-pack-manifest.schema.json +160 -0
- package/bundle/schemas/connector-definition.schema.json +29 -0
- package/bundle/schemas/data-service-definition.schema.json +64 -0
- package/bundle/schemas/data-view-definition.schema.json +148 -0
- package/bundle/schemas/entity-aggregation-config.schema.json +32 -0
- package/bundle/schemas/entity-cadence-config.schema.json +27 -0
- package/bundle/schemas/entity-compliance-config.schema.json +31 -0
- package/bundle/schemas/entity-cross-plugin-action-config.schema.json +31 -0
- package/bundle/schemas/entity-definition.schema.json +126 -0
- package/bundle/schemas/entity-document-generator-config.schema.json +29 -0
- package/bundle/schemas/entity-rule-definition.schema.json +75 -0
- package/bundle/schemas/entity-status-date-sweep-config.schema.json +21 -0
- package/bundle/schemas/form.schema.json +281 -0
- package/bundle/schemas/menu.schema.json +106 -0
- package/bundle/schemas/mobile-nav.schema.json +53 -0
- package/bundle/schemas/page-template.schema.json +46 -0
- package/bundle/schemas/page.schema.json +292 -0
- package/bundle/schemas/plugin-manifest.schema.json +139 -0
- package/bundle/schemas/plugin-seed-data.schema.json +24 -0
- package/bundle/schemas/print-template-definition.schema.json +90 -0
- package/bundle/schemas/provider-definition.schema.json +27 -0
- package/bundle/schemas/report-definition.schema.json +96 -0
- package/bundle/schemas/rule.schema.json +82 -0
- package/bundle/schemas/workflow-definition.schema.json +42 -0
- package/bundle/validators/block-engine.mjs +23222 -0
- package/bundle/validators/page-engine.mjs +21645 -0
- package/erp-cli/authoring-root.mjs +344 -0
- package/erp-cli/erp.mjs +4065 -0
- package/erp-cli/example-patterns.mjs +94 -0
- package/erp-mcp-server/index.mjs +818 -0
- package/package.json +27 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ERP Plugin Language MCP server — the concrete first slice of the
|
|
4
|
+
* "AI-Native Developer Platform" architecture in
|
|
5
|
+
* `low-code-docs/AI Native/AI_NATIVE_Developer platform.md`.
|
|
6
|
+
*
|
|
7
|
+
* Gap this closes: that doc's core, highest-leverage ask ("give Claude Code
|
|
8
|
+
* an ERP MCP server" so the AI discovers the plugin grammar instead of
|
|
9
|
+
* guessing it) had zero real implementation — `McpToolContribution`
|
|
10
|
+
* (engine-plugin-api) is a real per-plugin extension POINT, but nothing in
|
|
11
|
+
* this codebase actually hosts an MCP transport/server that speaks to an
|
|
12
|
+
* IDE agent. This file is that server: a plain stdio JSON-RPC 2.0 process
|
|
13
|
+
* (the MCP wire protocol), zero new npm dependencies (same
|
|
14
|
+
* standalone-outside-the-pnpm-workspace convention as `spark.js` and
|
|
15
|
+
* `validate-page-properties.mjs`, which it wraps rather than re-implements).
|
|
16
|
+
*
|
|
17
|
+
* What is deliberately NOT built here (disclosed, not silently dropped —
|
|
18
|
+
* see `ai/domains/ai-native-developer-platform-gap-analysis.md`):
|
|
19
|
+
* - `erp login`/OAuth device-auth, a plugin registry, `erp plugin pull/push`,
|
|
20
|
+
* a hosted `erp dev` environment — all real infra projects (auth server,
|
|
21
|
+
* registry service) orthogonal to "expose what already exists to an
|
|
22
|
+
* MCP client", not a natural extension of this one file.
|
|
23
|
+
* - A Language Server (LSP) — a separate, much larger surface.
|
|
24
|
+
* - `erp_create_plugin`/`erp_update_plugin` (AI writes files directly via
|
|
25
|
+
* its own Read/Write/Edit tools already — this server's job is
|
|
26
|
+
* grounding, not another file-mutation path).
|
|
27
|
+
*
|
|
28
|
+
* Tools exposed (Layer 1 structure / Layer 2 semantics / Layer 4 examples
|
|
29
|
+
* from the doc's own "four layers of grammar" framing):
|
|
30
|
+
* - erp_list_schemas / erp_get_schema — the real JSON Schemas this repo
|
|
31
|
+
* already ships (frontend/packages/*\/schema, backend/platform-runtime/schema)
|
|
32
|
+
* - erp_validate_page — wraps tools/page-validator's real
|
|
33
|
+
* validateInstanceConfig-based validator (unknown-property/invalid-binding/
|
|
34
|
+
* missing-required/page-structural checks), not a re-derived one
|
|
35
|
+
* - erp_validate_plugin_pages — wraps spark.js's validatePages
|
|
36
|
+
* - erp_search_docs — greps ai/ (the doc convention)
|
|
37
|
+
* - erp_search_examples — greps real shipped page/workflow/
|
|
38
|
+
* entity/provider JSON across backend/modules for a naming pattern
|
|
39
|
+
* - erp_service_plugin_scaffold — wraps platform-cli's REAL
|
|
40
|
+
* `service-plugin:scaffold` command (scaffoldServicePlugin, from
|
|
41
|
+
* ../../frontend/packages/platform-cli/dist/index.js) to generate a
|
|
42
|
+
* runnable Python/FastAPI or Node.js/Express Service-mode plugin
|
|
43
|
+
* starter on disk — the polyglot (non-Java) counterpart to
|
|
44
|
+
* erp_plugin_scaffold, which is Java/PF4J-extension-point-only
|
|
45
|
+
*/
|
|
46
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
47
|
+
import path from "node:path";
|
|
48
|
+
import { fileURLToPath } from "node:url";
|
|
49
|
+
import readline from "node:readline";
|
|
50
|
+
import { pathToFileURL } from "node:url";
|
|
51
|
+
import { EXAMPLE_PATTERNS } from "../erp-cli/example-patterns.mjs";
|
|
52
|
+
import { simpleSchemaCheck, buildEngineCatalog, searchEngineCatalog, describeContract, SCAFFOLD_TEMPLATES } from "../erp-cli/erp.mjs";
|
|
53
|
+
import {
|
|
54
|
+
SCHEMA_REGISTRY,
|
|
55
|
+
detectMode,
|
|
56
|
+
isPackaged,
|
|
57
|
+
requireMonorepo,
|
|
58
|
+
bundleDir,
|
|
59
|
+
schemaPathByName,
|
|
60
|
+
docsSearchRoots,
|
|
61
|
+
examplesModulesRoot,
|
|
62
|
+
exampleFileAbs,
|
|
63
|
+
blockEngineModuleUrl,
|
|
64
|
+
blocksCatalogPath,
|
|
65
|
+
} from "../erp-cli/authoring-root.mjs";
|
|
66
|
+
|
|
67
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
68
|
+
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Schema registry — shared single source of truth (tools/erp-cli/authoring-root.mjs),
|
|
72
|
+
// so the MCP server, the CLI, and the bundle emitter can never drift. In
|
|
73
|
+
// packaged SDK mode these are read from the shipped erp-authoring-bundle/;
|
|
74
|
+
// in monorepo mode, live from backend/platform-runtime + frontend/packages.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
const SCHEMAS = SCHEMA_REGISTRY;
|
|
77
|
+
function readSchema(name) {
|
|
78
|
+
const entry = SCHEMAS[name];
|
|
79
|
+
if (!entry) {
|
|
80
|
+
const known = Object.keys(SCHEMAS).join(", ");
|
|
81
|
+
throw new Error(`unknown schema "${name}". Known schemas: ${known}`);
|
|
82
|
+
}
|
|
83
|
+
const abs = schemaPathByName(name);
|
|
84
|
+
return { path: entry.file, description: entry.description, schema: JSON.parse(readFileSync(abs, "utf8")) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// erp_validate_page — reuse the REAL page validator, don't re-derive it.
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
async function validatePageTool(pagePath) {
|
|
91
|
+
// Works in BOTH modes: the page validator resolves @erp/block-engine +
|
|
92
|
+
// @erp/page-engine through authoring-root.mjs (dist in monorepo mode, the
|
|
93
|
+
// bundle's self-contained validators/*.mjs in packaged mode).
|
|
94
|
+
const mod = await import(pathToFileURL(path.join(REPO_ROOT, "tools/page-validator/validate-page-properties.mjs")).href);
|
|
95
|
+
const registry = await mod.buildRegistry();
|
|
96
|
+
const abs = path.isAbsolute(pagePath) ? pagePath : path.join(REPO_ROOT, pagePath);
|
|
97
|
+
const issues = mod.validatePage(abs, registry);
|
|
98
|
+
return issues.length === 0
|
|
99
|
+
? { file: pagePath, valid: true }
|
|
100
|
+
: { file: pagePath, valid: false, issues };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// erp_validate_connector_definition — local schema check for engine-provider-
|
|
105
|
+
// connector authoring files, reusing the SAME simpleSchemaCheck erp.mjs's own
|
|
106
|
+
// `erp connector validate`/`erp schema validate` use (imported, not
|
|
107
|
+
// re-derived) — mirrors this MCP server's "validate tools are local
|
|
108
|
+
// schema/lint checks" convention (erp_validate_page is the one exception,
|
|
109
|
+
// which reuses a real runtime validator instead of a schema).
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
async function validateConnectorDefinitionTool(filePath) {
|
|
112
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.join(REPO_ROOT, filePath);
|
|
113
|
+
const schema = readSchema("connector-definition").schema;
|
|
114
|
+
const data = JSON.parse(readFileSync(abs, "utf8"));
|
|
115
|
+
const issues = simpleSchemaCheck(data, schema, "$");
|
|
116
|
+
if (data.connectorKind === "HTTP" && !data.baseUrl) issues.push('connectorKind="HTTP" requires "baseUrl"');
|
|
117
|
+
return issues.length === 0 ? { file: filePath, valid: true } : { file: filePath, valid: false, issues };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function validatePluginPagesTool(pluginDir) {
|
|
121
|
+
// spark.js's own `validate` command runs this exact check but as a bare
|
|
122
|
+
// script with no exports (confirmed live — it has zero `export`
|
|
123
|
+
// statements, unlike validate-page-properties.mjs). Rather than add an
|
|
124
|
+
// export surface to spark.js for one caller, reuse page-validator's own
|
|
125
|
+
// exports directly over the same metadata/page directory spark.js walks.
|
|
126
|
+
const mod = await import(pathToFileURL(path.join(REPO_ROOT, "tools/page-validator/validate-page-properties.mjs")).href);
|
|
127
|
+
const abs = path.isAbsolute(pluginDir) ? pluginDir : path.join(REPO_ROOT, pluginDir);
|
|
128
|
+
const pageDir = path.join(abs, "metadata", "page");
|
|
129
|
+
let files;
|
|
130
|
+
try {
|
|
131
|
+
files = readdirSync(pageDir).filter((f) => f.endsWith(".json")).map((f) => path.join(pageDir, f));
|
|
132
|
+
} catch {
|
|
133
|
+
return { pluginDir, pagesChecked: 0, valid: true, note: `no page JSON found under ${path.relative(REPO_ROOT, pageDir)}` };
|
|
134
|
+
}
|
|
135
|
+
const registry = await mod.buildRegistry();
|
|
136
|
+
const failures = {};
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
const issues = mod.validatePage(file, registry);
|
|
139
|
+
if (issues.length > 0) failures[path.relative(REPO_ROOT, file)] = issues;
|
|
140
|
+
}
|
|
141
|
+
const result = { pluginDir, pagesChecked: files.length, valid: Object.keys(failures).length === 0 };
|
|
142
|
+
if (Object.keys(failures).length > 0) result.failures = failures;
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// erp_lint_plugin_semantics — [REAL, added 2026-09-03, I18N-001] MCP-native
|
|
148
|
+
// wiring for the SAME semantic lints `erp plugin test` runs
|
|
149
|
+
// (page-semantic-lint/DS-001/I18N-001) — these previously only existed
|
|
150
|
+
// CLI-side inside `tools/erp-cli/erp.mjs`'s own `pluginTestCommand`, so an
|
|
151
|
+
// AI/developer driving this repo purely through MCP tools had no way to run
|
|
152
|
+
// them without shelling out. `erp.mjs` is now import-safe (its `main()` is
|
|
153
|
+
// guarded to only run when it's the actual process entry point) and exports
|
|
154
|
+
// `lintPageSemantics`/`lintDataServiceSemantics`/`lintI18nSemantics`/
|
|
155
|
+
// `loadEntityFieldTypes`/`loadI18nKeys` for exactly this reuse — this
|
|
156
|
+
// function is a thin wrapper, not a re-derivation, so the two surfaces can
|
|
157
|
+
// never silently drift apart.
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
async function lintPluginSemanticsTool(pluginDir) {
|
|
160
|
+
// Pure JS — reads the plugin's own JSON + bundled schemas. Works packaged:
|
|
161
|
+
// the repo-wide helpers (loadAllRepoProviderNames, the java-dir scan) all
|
|
162
|
+
// degrade to empty when backend/modules isn't present, and every schema
|
|
163
|
+
// read goes through authoring-root.mjs's resolver.
|
|
164
|
+
const cli = await import(pathToFileURL(path.join(REPO_ROOT, "tools/erp-cli/erp.mjs")).href);
|
|
165
|
+
const abs = path.isAbsolute(pluginDir) ? pluginDir : path.join(REPO_ROOT, pluginDir);
|
|
166
|
+
const pageDir = path.join(abs, "metadata", "page");
|
|
167
|
+
const dsDir = path.join(abs, "metadata", "data_service");
|
|
168
|
+
const i18nKeys = cli.loadI18nKeys(abs);
|
|
169
|
+
const providerNamesSamePlugin = cli.loadProviderNamesFrom(path.join(abs, "metadata", "provider"));
|
|
170
|
+
const warnings = [];
|
|
171
|
+
|
|
172
|
+
let pageFiles = [];
|
|
173
|
+
try { pageFiles = readdirSync(pageDir).filter((f) => f.endsWith(".json")); } catch { /* no pages */ }
|
|
174
|
+
for (const f of pageFiles) {
|
|
175
|
+
try {
|
|
176
|
+
const pageJson = JSON.parse(readFileSync(path.join(pageDir, f), "utf8"));
|
|
177
|
+
const entityTypes = cli.loadEntityFieldTypes(abs, pageJson.metadata?.entityDefinition);
|
|
178
|
+
for (const w of cli.lintPageSemantics(pageJson, entityTypes)) warnings.push({ file: `page/${f}`, rule: w.match(/\[([a-z-]+)\]/)?.[1] ?? "page-semantic-lint", message: w });
|
|
179
|
+
for (const w of cli.lintI18nSemantics(pageJson, i18nKeys)) warnings.push({ file: `page/${f}`, rule: w.match(/\[([a-z-]+)\]/)?.[1] ?? "I18N-001", message: w });
|
|
180
|
+
for (const w of cli.lintPageDataSourceSemantics(pageJson, providerNamesSamePlugin, cli.loadAllRepoProviderNames())) warnings.push({ file: `page/${f}`, rule: w.match(/\[([a-z-]+)\]/)?.[1] ?? "PAGE-DS-001", message: w });
|
|
181
|
+
} catch (e) {
|
|
182
|
+
warnings.push({ file: `page/${f}`, rule: "lint-error", message: `could not lint: ${e.message}` });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let dsFiles = [];
|
|
187
|
+
try { dsFiles = readdirSync(dsDir).filter((f) => f.endsWith(".json")); } catch { /* no data services */ }
|
|
188
|
+
for (const f of dsFiles) {
|
|
189
|
+
try {
|
|
190
|
+
const dsJson = JSON.parse(readFileSync(path.join(dsDir, f), "utf8"));
|
|
191
|
+
const entityName = dsJson.definition?.source?.kind === "entity" ? dsJson.definition.source.entityName : null;
|
|
192
|
+
const entityTypes = cli.loadEntityFieldTypes(abs, entityName);
|
|
193
|
+
for (const w of cli.lintDataServiceSemantics(dsJson, entityTypes)) warnings.push({ file: `data_service/${f}`, rule: "DS-001", message: w });
|
|
194
|
+
} catch (e) {
|
|
195
|
+
warnings.push({ file: `data_service/${f}`, rule: "lint-error", message: `could not lint: ${e.message}` });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// JOB-001 (2026-09-04, REAL) — same lintJobContributionUsage the CLI's
|
|
200
|
+
// `erp plugin test` runs, scanning this plugin's own src/main/java for a
|
|
201
|
+
// new bespoke ErpJobContribution class instead of a JSON job definition
|
|
202
|
+
// against an already-generic sweep engine.
|
|
203
|
+
const javaDir = path.join(abs, "..", "src", "main", "java");
|
|
204
|
+
try {
|
|
205
|
+
for (const w of cli.lintJobContributionUsage(javaDir)) warnings.push({ file: "src/main/java", rule: "JOB-001", message: w });
|
|
206
|
+
} catch (e) {
|
|
207
|
+
warnings.push({ file: "src/main/java", rule: "lint-error", message: `could not lint: ${e.message}` });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// SEED-001 (2026-09-10, REAL) — validate metadata/seed-data/*.json against
|
|
211
|
+
// plugin-seed-data.schema.json + the per-config-entity row schemas, and
|
|
212
|
+
// sanity-check that a seed row's target entity is shipped by this plugin or
|
|
213
|
+
// is a known shared platform config entity. See ai/patterns/plugin-install-data-seeding.md.
|
|
214
|
+
const seedDir = path.join(abs, "metadata", "seed-data");
|
|
215
|
+
const SHARED_CONFIG_ENTITIES = {
|
|
216
|
+
entity_status_date_sweep_config: "entity-status-date-sweep-config",
|
|
217
|
+
entity_aggregation_config: "entity-aggregation-config",
|
|
218
|
+
entity_cadence_config: "entity-cadence-config",
|
|
219
|
+
entity_compliance_config: "entity-compliance-config",
|
|
220
|
+
entity_cross_plugin_action_config: "entity-cross-plugin-action-config",
|
|
221
|
+
entity_document_generator_config: "entity-document-generator-config",
|
|
222
|
+
workflow_reminder_config: null,
|
|
223
|
+
data_service_snapshot_config: null,
|
|
224
|
+
platform_recommendation_rule: null,
|
|
225
|
+
platform_rescore_sweep_config: null,
|
|
226
|
+
};
|
|
227
|
+
let ownEntities = new Set();
|
|
228
|
+
try { ownEntities = new Set(readdirSync(path.join(abs, "metadata", "entities")).filter((f) => f.endsWith(".json")).map((f) => f.replace(/\.json$/, ""))); } catch { /* none */ }
|
|
229
|
+
let seedFiles = [];
|
|
230
|
+
try { seedFiles = readdirSync(seedDir).filter((f) => f.endsWith(".json")); } catch { /* no seed-data */ }
|
|
231
|
+
const seedFileSchema = JSON.parse(readFileSync(schemaPathByName("plugin-seed-data"), "utf8"));
|
|
232
|
+
for (const f of seedFiles) {
|
|
233
|
+
try {
|
|
234
|
+
const doc = JSON.parse(readFileSync(path.join(seedDir, f), "utf8"));
|
|
235
|
+
for (const issue of simpleSchemaCheck(doc, seedFileSchema, "$")) warnings.push({ file: `seed-data/${f}`, rule: "SEED-001", message: issue });
|
|
236
|
+
if (doc.entity && !(doc.entity in SHARED_CONFIG_ENTITIES) && !ownEntities.has(doc.entity)) {
|
|
237
|
+
warnings.push({ file: `seed-data/${f}`, rule: "SEED-001", message: `[SEED-001] seed target entity "${doc.entity}" is neither shipped by this plugin (metadata/entities/${doc.entity}.json) nor a known shared platform config entity — it must exist on the tenant before install or the seed is silently skipped` });
|
|
238
|
+
}
|
|
239
|
+
const rowSchemaName = SHARED_CONFIG_ENTITIES[doc.entity];
|
|
240
|
+
if (rowSchemaName && Array.isArray(doc.rows)) {
|
|
241
|
+
const rowSchema = readSchema(rowSchemaName).schema;
|
|
242
|
+
doc.rows.forEach((row, i) => {
|
|
243
|
+
for (const issue of simpleSchemaCheck(row, rowSchema, `$.rows[${i}]`)) warnings.push({ file: `seed-data/${f}`, rule: "SEED-001", message: issue });
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
} catch (e) {
|
|
247
|
+
warnings.push({ file: `seed-data/${f}`, rule: "lint-error", message: `could not lint: ${e.message}` });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { pluginDir, pagesChecked: pageFiles.length, dataServicesChecked: dsFiles.length, seedFilesChecked: seedFiles.length, warningCount: warnings.length, warnings };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// erp_search_docs — grep across the ai/ doc convention (not low-code-docs/).
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
function walkFiles(dir, exts, out = []) {
|
|
258
|
+
let entries;
|
|
259
|
+
try {
|
|
260
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
261
|
+
} catch {
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
for (const e of entries) {
|
|
265
|
+
const full = path.join(dir, e.name);
|
|
266
|
+
if (e.isDirectory()) walkFiles(full, exts, out);
|
|
267
|
+
else if (exts.some((ext) => e.name.endsWith(ext))) out.push(full);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// [FIXED 2026-09-04, real gap] — this used to search ONLY hand-written
|
|
273
|
+
// ai/domains/*.md narrative docs, with no way to check a doc's claim against
|
|
274
|
+
// the actual platform-runtime source. That let a false "no report-builder
|
|
275
|
+
// infrastructure exists anywhere in this codebase" claim propagate unverified
|
|
276
|
+
// across five module docs even though a real ReportDefinitionService/
|
|
277
|
+
// BiReportDefinitionService engine existed the whole time. Every result is
|
|
278
|
+
// now tagged `source: "ai/domains doc"` (a human-curated claim — may be
|
|
279
|
+
// stale) or `source: "live engine-* source"` (mechanically re-derived by
|
|
280
|
+
// `erp platform catalog`, see erp.mjs's own buildEngineCatalog/
|
|
281
|
+
// searchEngineCatalog — cannot be stale in the same way) so a query can never
|
|
282
|
+
// again come back with ONLY the possibly-stale half.
|
|
283
|
+
function searchDocsTool(query, maxResults = 20) {
|
|
284
|
+
const q = query.toLowerCase();
|
|
285
|
+
const docHits = [];
|
|
286
|
+
for (const root of docsSearchRoots()) {
|
|
287
|
+
const files = walkFiles(root.dir, [".md"]);
|
|
288
|
+
for (const file of files) {
|
|
289
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
290
|
+
lines.forEach((line, i) => {
|
|
291
|
+
if (line.toLowerCase().includes(q)) {
|
|
292
|
+
docHits.push({ source: root.source, file: path.relative(root.relBase, file), line: i + 1, text: line.trim() });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
if (docHits.length >= maxResults) break;
|
|
296
|
+
}
|
|
297
|
+
if (docHits.length >= maxResults) break;
|
|
298
|
+
}
|
|
299
|
+
let engineHits = [];
|
|
300
|
+
try {
|
|
301
|
+
engineHits = searchEngineCatalog(query).hits.slice(0, maxResults).map((h) => ({
|
|
302
|
+
source: h.kind === "java-contract" ? "java contract" : "live engine-* source",
|
|
303
|
+
engine: h.engine,
|
|
304
|
+
kind: h.kind,
|
|
305
|
+
match: h.match,
|
|
306
|
+
file: h.file ?? null,
|
|
307
|
+
unit: h.unit ?? undefined,
|
|
308
|
+
}));
|
|
309
|
+
} catch (e) {
|
|
310
|
+
engineHits = [{ source: "live engine-* source", error: `catalog unavailable: ${e.message}` }];
|
|
311
|
+
}
|
|
312
|
+
// `matches`/`truncated` keep their EXACT original shape (ai/domains/*.md
|
|
313
|
+
// doc hits only) — no existing caller of this tool breaks. `engineMatches`
|
|
314
|
+
// is purely additive: the live engine-* source hits this fix adds.
|
|
315
|
+
return {
|
|
316
|
+
query,
|
|
317
|
+
matches: docHits.slice(0, maxResults),
|
|
318
|
+
truncated: docHits.length > maxResults,
|
|
319
|
+
engineMatches: engineHits,
|
|
320
|
+
note: "`matches` are human-curated narrative claims (ai/domains/*.md) — check `engineMatches` (mechanically scanned from real backend/platform-runtime/engine-* source, via erp.mjs's buildEngineCatalog/searchEngineCatalog) before treating an absence in `matches` as proof a capability doesn't exist.",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// erp_search_examples — real shipped metadata JSON across backend/modules,
|
|
326
|
+
// per the doc's Layer-4 "how do I combine these correctly" ask.
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
function searchExamplesTool(pattern, kind = "page", maxResults = 10) {
|
|
329
|
+
const modulesDir = examplesModulesRoot();
|
|
330
|
+
// 2026-09-03, real gap found and fixed (mirrored in tools/erp-cli/erp.mjs's own
|
|
331
|
+
// examplesSearchCommand): data-service/data-view/rules/menu were entirely
|
|
332
|
+
// unsearchable, so a developer/AI looking for a real "searchable related-record
|
|
333
|
+
// lookup" Data Service precedent (the core.lookup optionsSourceKey recipe) had no
|
|
334
|
+
// way to find one through this tool at all.
|
|
335
|
+
const kindDirs = { page: "page", workflow: "workflow", entities: "entities", provider: "provider", "data-service": "data_service", "data-view": "data_view", rules: "rules", menu: "menu" };
|
|
336
|
+
const sub = kindDirs[kind];
|
|
337
|
+
if (!sub) throw new Error(`unknown kind "${kind}". Known kinds: ${Object.keys(kindDirs).join(", ")}`);
|
|
338
|
+
const results = [];
|
|
339
|
+
let moduleDirs;
|
|
340
|
+
try {
|
|
341
|
+
moduleDirs = readdirSync(modulesDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
342
|
+
} catch {
|
|
343
|
+
return { pattern, kind, matches: [] };
|
|
344
|
+
}
|
|
345
|
+
const p = pattern.toLowerCase();
|
|
346
|
+
for (const mod of moduleDirs) {
|
|
347
|
+
const target = path.join(modulesDir, mod.name, "spk-assembly", "metadata", sub);
|
|
348
|
+
let files;
|
|
349
|
+
try {
|
|
350
|
+
files = readdirSync(target).filter((f) => f.endsWith(".json"));
|
|
351
|
+
} catch {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
for (const f of files) {
|
|
355
|
+
if (f.toLowerCase().includes(p) || mod.name.toLowerCase().includes(p)) {
|
|
356
|
+
results.push(path.join("backend", "modules", path.relative(modulesDir, path.join(target, f))).replace(/\\/g, "/"));
|
|
357
|
+
}
|
|
358
|
+
if (results.length >= maxResults) break;
|
|
359
|
+
}
|
|
360
|
+
if (results.length >= maxResults) break;
|
|
361
|
+
}
|
|
362
|
+
return { pattern, kind, matches: results };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// erp_examples_patterns — [REAL, added 2026-09-03] MCP mirror of the CLI's
|
|
367
|
+
// `erp examples patterns` — a curated catalog of named SHAPES (not just
|
|
368
|
+
// filenames), each pointing at a real shipped file confirmed to actually
|
|
369
|
+
// have that shape. Real gap this closes: `erp_search_examples` above only
|
|
370
|
+
// ever returns filenames matching a substring, which still requires an AI
|
|
371
|
+
// driving purely through MCP (no filesystem/source access otherwise) to
|
|
372
|
+
// read N files to find the ONE real precedent for a named shape (e.g.
|
|
373
|
+
// "single-actor self-decide workflow", or — added 2026-09-03 — "mark a
|
|
374
|
+
// foreign-key field for automatic id->display-name resolution"). Shares
|
|
375
|
+
// ONE catalog (tools/erp-cli/example-patterns.mjs) with the CLI command so
|
|
376
|
+
// a developer on either surface sees the identical list, never a drifted
|
|
377
|
+
// copy — see that file's own doc comment.
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
function examplesPatternsTool(kind) {
|
|
380
|
+
const kinds = kind ? [kind] : Object.keys(EXAMPLE_PATTERNS);
|
|
381
|
+
const out = {};
|
|
382
|
+
for (const k of kinds) {
|
|
383
|
+
const patterns = EXAMPLE_PATTERNS[k];
|
|
384
|
+
if (!patterns) { out[k] = { error: `no curated patterns for kind "${k}" — known kinds: ${Object.keys(EXAMPLE_PATTERNS).join(", ")}` }; continue; }
|
|
385
|
+
out[k] = patterns.map((p) => ({
|
|
386
|
+
name: p.name,
|
|
387
|
+
description: p.description,
|
|
388
|
+
file: p.file,
|
|
389
|
+
fileExists: existsSync(exampleFileAbs(p.file)),
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// erp_list_blocks — [REAL, added 2026-09-04] mirrors tools/erp-cli/erp.mjs's
|
|
397
|
+
// `erp blocks list` exactly, same real gap/same fix: no MCP tool enumerated
|
|
398
|
+
// the actual widget/block catalog (~124 real types) with their real
|
|
399
|
+
// properties/events/outputs — `erp_list_schemas`'s `block-definition`/
|
|
400
|
+
// `block-instance` only describe the generic per-INSTANCE contract shape,
|
|
401
|
+
// not what each concrete blockType (core.grid, core.lookup, ...) itself
|
|
402
|
+
// accepts. Derived live from @erp/block-engine's own built dist
|
|
403
|
+
// (`coreBlockTypes()`), never hand-maintained, so it can't drift.
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
async function loadBlockDefinitionsTool() {
|
|
406
|
+
// Packaged mode: the frozen blocks.json in the authoring bundle. Monorepo
|
|
407
|
+
// mode: derived live from @erp/block-engine's own dist registry.
|
|
408
|
+
const catalogPath = blocksCatalogPath();
|
|
409
|
+
if (catalogPath) {
|
|
410
|
+
return JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
411
|
+
}
|
|
412
|
+
const mod = await import(blockEngineModuleUrl());
|
|
413
|
+
return mod.coreBlockTypes().map((bt) => bt.definition);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function summarizeBlockDefinitionTool(def) {
|
|
417
|
+
return {
|
|
418
|
+
type: def.type,
|
|
419
|
+
category: def.designer?.category ?? null,
|
|
420
|
+
displayNameKey: def.designer?.displayNameKey ?? null,
|
|
421
|
+
descriptionKey: def.designer?.descriptionKey ?? null,
|
|
422
|
+
icon: def.designer?.icon ?? null,
|
|
423
|
+
allowedTargets: def.designer?.allowedTargets ?? [],
|
|
424
|
+
inputClass: !!def.inputClass,
|
|
425
|
+
properties: (def.properties ?? []).map((p) => ({ name: p.name, type: p.type, sources: p.sources ?? [], editor: p.designer?.editor ?? null })),
|
|
426
|
+
outputs: (def.outputs ?? []).map((o) => ({ name: o.name, type: o.type })),
|
|
427
|
+
events: (def.events ?? []).map((e) => ({ name: e.name, catalogType: e.catalogType ?? null })),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function listBlocksTool(type, category) {
|
|
432
|
+
const defs = await loadBlockDefinitionsTool();
|
|
433
|
+
let filtered = defs;
|
|
434
|
+
if (type) {
|
|
435
|
+
const needle = type.toLowerCase();
|
|
436
|
+
filtered = filtered.filter((d) => d.type.toLowerCase().includes(needle));
|
|
437
|
+
}
|
|
438
|
+
if (category) {
|
|
439
|
+
const needle = category.toLowerCase();
|
|
440
|
+
filtered = filtered.filter((d) => (d.designer?.category ?? "").toLowerCase() === needle);
|
|
441
|
+
}
|
|
442
|
+
if (type && filtered.length === 1) {
|
|
443
|
+
return filtered[0]; // exact-ish single match: full raw definition, every designer/labelKey detail
|
|
444
|
+
}
|
|
445
|
+
return { totalRegistered: defs.length, matched: filtered.length, blocks: filtered.map(summarizeBlockDefinitionTool) };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// erp_tail_logs — [REAL, added 2026-09-04] mirrors tools/erp-cli/erp.mjs's
|
|
450
|
+
// `erp logs tail` exactly, same real gap/same fix: engine-api logged to
|
|
451
|
+
// console ONLY (see application.yml's `logging.file.name`, added alongside
|
|
452
|
+
// this) — an AI driving purely through MCP had no way to see server-side
|
|
453
|
+
// error detail (a real stack trace, not just an HTTP response's own message)
|
|
454
|
+
// for a failed call. Every real API response carries a real
|
|
455
|
+
// `X-Correlation-Id` header matching the log's own `[cid=...]` token on
|
|
456
|
+
// every line for that request — pass it as `grep` to get exactly that
|
|
457
|
+
// request's real trace. No `--follow` here (an MCP tool call is one
|
|
458
|
+
// request/response, not a long-lived stream) — call it again for fresh
|
|
459
|
+
// lines.
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
function tailLogsTool(lines = 200, grep, file) {
|
|
462
|
+
requireMonorepo("erp_tail_logs");
|
|
463
|
+
const logPath = file ? path.resolve(file) : path.join(REPO_ROOT, "backend/platform-runtime/engine-api/logs/engine-api.log");
|
|
464
|
+
if (!existsSync(logPath)) {
|
|
465
|
+
throw new Error(`no log file at ${path.relative(REPO_ROOT, logPath)} — engine-api needs to have been (re)started since logging.file.name was set.`);
|
|
466
|
+
}
|
|
467
|
+
let allLines = readFileSync(logPath, "utf8").split("\n");
|
|
468
|
+
if (grep) allLines = allLines.filter((l) => l.includes(grep));
|
|
469
|
+
if (allLines.length === 0) return { matched: 0, lines: [] };
|
|
470
|
+
const tail = allLines.slice(-lines);
|
|
471
|
+
return { matched: allLines.length, lines: tail };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// erp_service_plugin_scaffold — [REAL, added 2026-09-14, Polyglot Plugin
|
|
476
|
+
// Runtime initiative] wraps the REAL `platform-cli service-plugin:scaffold`
|
|
477
|
+
// command (frontend/packages/platform-cli/src/servicePluginScaffold.ts's
|
|
478
|
+
// `scaffoldServicePlugin`, already a clean, directly-callable exported
|
|
479
|
+
// function — no adaptation needed, it was already used this way by
|
|
480
|
+
// cli.ts's own `service-plugin:scaffold` case and by
|
|
481
|
+
// servicePluginScaffold.test.ts, so this tool is a thin wrapper, not a
|
|
482
|
+
// re-derivation). Real gap this closes: erp_plugin_scaffold above only
|
|
483
|
+
// emits Java/PF4J extension-point skeletons (rest-contribution/
|
|
484
|
+
// seed-data-config) — makes sense, those ARE Java-only PF4J concepts — but
|
|
485
|
+
// a Service-mode plugin (a standalone Python/FastAPI or Node.js/Express
|
|
486
|
+
// process that self-registers against engine-api, never PF4J-loaded; see
|
|
487
|
+
// servicePluginScaffold.ts's own doc comment) had NO MCP-visible scaffold
|
|
488
|
+
// path at all, even though the real CLI command has existed since this
|
|
489
|
+
// same initiative's earlier batch. Both the disk-writing (`writeScaffoldToDisk`)
|
|
490
|
+
// and in-memory (`scaffoldServicePlugin`) halves are imported from
|
|
491
|
+
// platform-cli's own built dist/index.js — same "wrap real code" convention
|
|
492
|
+
// every other tool in this file follows, no template logic re-implemented
|
|
493
|
+
// here.
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
async function serviceScaffoldTool(args) {
|
|
496
|
+
requireMonorepo("erp_service_plugin_scaffold");
|
|
497
|
+
if (!args.id) throw new Error('"id" is required, e.g. "acme-inventory-sync".');
|
|
498
|
+
if (args.runtime !== "python" && args.runtime !== "nodejs") {
|
|
499
|
+
throw new Error(`"runtime" must be "python" or "nodejs" (got ${JSON.stringify(args.runtime)}).`);
|
|
500
|
+
}
|
|
501
|
+
if (!args.out) throw new Error('"out" is required — the directory to scaffold the project into (absolute or repo-root-relative).');
|
|
502
|
+
|
|
503
|
+
// dist/index.js is the library entry (`main`/`exports["."]`) — cli.js is
|
|
504
|
+
// the argv-parsing entrypoint (see package.json's own `bin`/`exports`
|
|
505
|
+
// split); we call the real exported functions directly, exactly as
|
|
506
|
+
// cli.ts's `service-plugin:scaffold` case and the package's own vitest
|
|
507
|
+
// suite already do, rather than shelling out to the CLI as a subprocess.
|
|
508
|
+
const cliMod = await import(pathToFileURL(path.join(REPO_ROOT, "frontend/packages/platform-cli/dist/index.js")).href);
|
|
509
|
+
const project = cliMod.scaffoldServicePlugin({
|
|
510
|
+
id: args.id,
|
|
511
|
+
runtime: args.runtime,
|
|
512
|
+
...(args.vendor ? { vendor: args.vendor } : {}),
|
|
513
|
+
...(args.port ? { port: Number(args.port) } : {}),
|
|
514
|
+
});
|
|
515
|
+
const outDir = path.isAbsolute(args.out) ? args.out : path.join(REPO_ROOT, args.out);
|
|
516
|
+
const written = await cliMod.writeScaffoldToDisk(outDir, project);
|
|
517
|
+
return {
|
|
518
|
+
id: args.id,
|
|
519
|
+
runtime: args.runtime,
|
|
520
|
+
outDir,
|
|
521
|
+
fileCount: written.length,
|
|
522
|
+
files: written.map((f) => path.relative(outDir, f).replace(/\\/g, "/")),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// erp_list_grammar_rules — named RULE-xxx catalog per the doc's "give the AI
|
|
528
|
+
// grammar rules" ask. Every entry here is a rule a REAL mechanism already
|
|
529
|
+
// enforces today (page-validator or the erp CLI's workflow checks) — this
|
|
530
|
+
// tool documents/names them, it does not add new enforcement.
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
const GRAMMAR_RULES = [
|
|
533
|
+
{ code: "PAGE-001", rule: "A block instance's blockType must resolve to a registered block type (or be a known composite/custom type).", enforcedBy: "tools/page-validator/validate-page-properties.mjs (validateInstanceConfig, unknown-block-type)" },
|
|
534
|
+
{ code: "PAGE-002", rule: "A block instance's properties must only use property names/value shapes the block type declares (no unknown properties, no invalid bindings, correct enum/min/max/maxLength, no unknown events).", enforcedBy: "tools/page-validator/validate-page-properties.mjs (validateInstanceConfig)" },
|
|
535
|
+
{ code: "PAGE-003", rule: "A required property must actually be present.", enforcedBy: "tools/page-validator/validate-page-properties.mjs (missing-required-property)" },
|
|
536
|
+
{ code: "PAGE-004", rule: "A page's own top-level structure (rows/overlays/variants/designer metadata) must be structurally valid.", enforcedBy: "@erp/page-engine's validatePageDefinition, wired into tools/page-validator/validate-page-properties.mjs" },
|
|
537
|
+
{ code: "PAGE-005", rule: "An instanceId must be a plain kebab-case identifier — no whitespace.", enforcedBy: "tools/page-validator/validate-page-properties.mjs (hasWhitespaceInstanceId)" },
|
|
538
|
+
{ code: "PAGE-006", rule: "An action's order field, if present, must be an integer.", enforcedBy: "tools/page-validator/validate-page-properties.mjs (validateOrderFields)" },
|
|
539
|
+
{ code: "WF-001", rule: "Every transition's fromStage/toStage must be a stage the workflow actually declares.", enforcedBy: "tools/erp-cli/erp.mjs workflow validate" },
|
|
540
|
+
{ code: "WF-002", rule: "Every task's stage must be a declared stage.", enforcedBy: "tools/erp-cli/erp.mjs workflow validate" },
|
|
541
|
+
{ code: "WF-003", rule: "Every approvalPermissions entry's approvalObject must match a task that actually declares it.", enforcedBy: "tools/erp-cli/erp.mjs workflow validate" },
|
|
542
|
+
{ code: "WF-004", rule: "approvalPermissions references a role by its stable role.code, never a mutable display name.", enforcedBy: "PluginWorkflowInstaller.installApprovalPermissions (Phase 0 role-code fix) — convention, not schema-checked" },
|
|
543
|
+
{ code: "ENTITY-001", rule: "A role rule's role must reference a real role — enforced at write time, not by static JSON validation.", enforcedBy: "EntityRoleFieldRuleValidator (engine-entity)" },
|
|
544
|
+
{ code: "DS-001", rule: "An entity-backed Data Service filter's literal value should match its target field's declared dataType (a \"true\"/\"false\" literal against a boolean field, not a mismatched type) — the static, pre-deploy half of the real BadSqlGrammarException/type-coercion bug found live in HCM Talent Batch 1 (see ai/domains/hcm-talent-management.md); the runtime bug itself is fixed in BiReportQueryExecutor, this catches an author's own mismatched literal going forward.", enforcedBy: "tools/erp-cli/erp.mjs plugin test (lintDataServiceSemantics); also erp_lint_plugin_semantics (MCP)" },
|
|
545
|
+
{ code: "I18N-001", rule: "Every i18n key a page references (labelKey/headerKey/textKey/titleKey/displayNameKey/descriptionKey, including select/lookup option labelKeys) must have a real entry in the plugin's own metadata/i18n/*.json — a missing key renders as a raw ⟦key⟧ placeholder at runtime (found live on HCM Compensation Batch 2's own allowances/benefits pages before this rule existed). A companion heuristic flags a value that is exactly the mechanical Title-Case of a camelCase key tail ending in Btn/Heading/Msg/Label/Toast (the \"newBtn\" -> \"New Btn\" regression class from HCM Talent Batch 2) as a likely un-crafted placeholder.", enforcedBy: "tools/erp-cli/erp.mjs plugin test (lintI18nSemantics); also erp_lint_plugin_semantics (MCP)" },
|
|
546
|
+
{ code: "PERM-SEC-001", rule: "A plugin must never hardcode a tenant id, environment URL, or credential — outgoing self-calls resolve via an env-var-with-localhost-fallback pattern instead.", enforcedBy: "convention, verified by direct code review per module (see feedback-plugins-must-be-environment-portable); not yet a static-analysis check any tool here runs automatically." },
|
|
547
|
+
{ code: "PAGE-DS-001", rule: "A page containing a core.grid block (in its own rows, or any responsive variant's rows) must set a top-level metadata.dataSource naming a real metadata/provider/*.json file's own \"name\" field — core.grid's own block properties (columns/pageSize/externalFilter/search/refreshTrigger) carry no dataSource/entity/recordType at all, so this page-level field is the ONLY real wiring TenantPageHost reads. Omitting it, or naming a provider that doesn't resolve (same plugin or any other shipped plugin), silently renders 0 rows with NO error — found live 3+ times on hcm-employee-documents alone before this rule existed (see ai/domains/ai-native-developer-platform-gap-analysis.md's \"core.grid's real data-source wiring\" section and ai/patterns/grid-data-source-wiring.md for the full chain).", enforcedBy: "tools/erp-cli/erp.mjs plugin test (lintPageDataSourceSemantics); also erp_lint_plugin_semantics (MCP)" },
|
|
548
|
+
{ code: "JOB-001", rule: "A plugin's own src/main/java should not add a new bespoke `@Extension implements ErpJobContribution` class without considering the generic, JSON-configured sweep engines first ([[feedback-json-first-scheduled-jobs]] / ai/domains/project-generic-erp-job-engine-target-architecture.md — e.g. EntityStatusDateSweepJob's entity_status_date_sweep_config for an \"entity status/date crosses now -> flip a field\" shape, WorkflowReminderSweepJob's workflow_reminder_config for a \"remind on a stale pending workflow task\" shape). WARN, not FAIL — genuinely complex job logic legitimately still needs custom Java (the *EffectiveDateJob family, the generic sweep engines themselves); this only makes the tradeoff visible pre-deploy instead of silently defaulting to Java.", enforcedBy: "tools/erp-cli/erp.mjs plugin test (lintJobContributionUsage); also erp_lint_plugin_semantics (MCP)" },
|
|
549
|
+
];
|
|
550
|
+
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// Tool catalog (JSON-RPC facing) — name/description/inputSchema/handler.
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
const TOOLS = [
|
|
555
|
+
{
|
|
556
|
+
name: "erp_list_schemas",
|
|
557
|
+
description: "List every ERP Plugin Language JSON Schema this platform formally defines (page, block, form, action, rule, plugin-manifest, entity-definition, entity-rule-definition, workflow-definition).",
|
|
558
|
+
inputSchema: { type: "object", properties: {} },
|
|
559
|
+
handler: async () => Object.fromEntries(Object.entries(SCHEMAS).map(([k, v]) => [k, v.description])),
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
name: "erp_get_schema",
|
|
563
|
+
description: "Fetch the full JSON Schema for one ERP Plugin Language artifact type. Call erp_list_schemas first if unsure of the name.",
|
|
564
|
+
inputSchema: {
|
|
565
|
+
type: "object",
|
|
566
|
+
required: ["name"],
|
|
567
|
+
properties: { name: { type: "string", enum: Object.keys(SCHEMAS) } },
|
|
568
|
+
},
|
|
569
|
+
handler: async (args) => readSchema(args.name),
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
name: "erp_validate_page",
|
|
573
|
+
description: "Validate one page JSON file against the real, running block-engine/page-engine validators (unknown properties, invalid bindings, missing required properties, bad enum values, page-structural defects) — the same check the runtime itself performs, run at authoring time.",
|
|
574
|
+
inputSchema: {
|
|
575
|
+
type: "object",
|
|
576
|
+
required: ["pagePath"],
|
|
577
|
+
properties: { pagePath: { type: "string", description: "Path to a page JSON file, absolute or repo-root-relative." } },
|
|
578
|
+
},
|
|
579
|
+
handler: async (args) => validatePageTool(args.pagePath),
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
name: "erp_validate_plugin_pages",
|
|
583
|
+
description: "Validate every page JSON under <pluginDir>/metadata/page/ (same check spark.js's own `validate` command runs before packaging).",
|
|
584
|
+
inputSchema: {
|
|
585
|
+
type: "object",
|
|
586
|
+
required: ["pluginDir"],
|
|
587
|
+
properties: { pluginDir: { type: "string", description: "A plugin's spk-assembly directory, absolute or repo-root-relative." } },
|
|
588
|
+
},
|
|
589
|
+
handler: async (args) => validatePluginPagesTool(args.pluginDir),
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
name: "erp_validate_connector_definition",
|
|
593
|
+
description: "Schema-check a Generic External Provider Connector Engine definition file (engine-provider-connector's connector-definition schema) — e.g. before running `erp connector create` to actually PUT it to the live backend. Flags a connectorKind=\"HTTP\" row missing baseUrl in addition to the plain schema check.",
|
|
594
|
+
inputSchema: {
|
|
595
|
+
type: "object",
|
|
596
|
+
required: ["filePath"],
|
|
597
|
+
properties: { filePath: { type: "string", description: "Path to a connector-definition JSON file, absolute or repo-root-relative." } },
|
|
598
|
+
},
|
|
599
|
+
handler: async (args) => validateConnectorDefinitionTool(args.filePath),
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
name: "erp_lint_plugin_semantics",
|
|
603
|
+
description: "Run the real semantic lints (page-semantic-lint: wrong-block-for-field/decorative-filters; DS-001: Data Service filter type mismatches; I18N-001: missing/placeholder i18n keys; PAGE-DS-001: a page with a core.grid block missing/mis-naming its top-level metadata.dataSource — the silent \"0 rows, no error\" bug class; JOB-001: a new bespoke ErpJobContribution class instead of a JSON job definition against an already-generic sweep engine) across every page/Data Service/Java source file under <pluginDir> — the same checks `erp plugin test` runs CLI-side, now callable directly over MCP. Heuristic WARNs, not hard failures — review each one.",
|
|
604
|
+
inputSchema: {
|
|
605
|
+
type: "object",
|
|
606
|
+
required: ["pluginDir"],
|
|
607
|
+
properties: { pluginDir: { type: "string", description: "A plugin's spk-assembly directory, absolute or repo-root-relative." } },
|
|
608
|
+
},
|
|
609
|
+
handler: async (args) => lintPluginSemanticsTool(args.pluginDir),
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
name: "erp_search_docs",
|
|
613
|
+
description: "Full-text search across ai/ (this repo's real, current architecture/pattern/domain docs — not low-code-docs/, which is raw spec input) PLUS a live, mechanically-derived scan of every backend/platform-runtime/engine-* module's real classes/REST mappings/Flyway tables (`engineMatches`, via `erp platform catalog`). Always check `engineMatches` before writing a 'this capability doesn't exist' disclosure — a hand-written ai/domains/*.md doc can be stale (found live: a false 'no report-builder infrastructure exists anywhere in this codebase' claim propagated across 5 module docs even though ReportDefinitionService/BiReportDefinitionService already existed).",
|
|
614
|
+
inputSchema: {
|
|
615
|
+
type: "object",
|
|
616
|
+
required: ["query"],
|
|
617
|
+
properties: { query: { type: "string" }, maxResults: { type: "integer", default: 20 } },
|
|
618
|
+
},
|
|
619
|
+
handler: async (args) => searchDocsTool(args.query, args.maxResults ?? 20),
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
name: "erp_platform_catalog",
|
|
623
|
+
description: "The real, mechanically-derived capability inventory of every backend/platform-runtime/engine-* module (public classes/interfaces, REST @*Mapping base paths, Flyway migration table names) — `erp platform catalog`'s MCP twin. Use this (or erp_search_docs, which already includes it) BEFORE writing any 'no X infrastructure exists' disclosure in a new module's docs. Omit both filters to list every engine module with counts; pass `engine` for one module's full class/REST/migration detail; pass `query` to search by substring/keyword across all modules (same matching erp_search_docs uses for its engineMatches).",
|
|
624
|
+
inputSchema: {
|
|
625
|
+
type: "object",
|
|
626
|
+
properties: {
|
|
627
|
+
engine: { type: "string", description: "Exact engine-* module name, e.g. \"engine-form\" — returns that module's full detail." },
|
|
628
|
+
query: { type: "string", description: "Search term across module names/classes/REST paths/migration tables, e.g. \"report builder\" or \"pluggable provider\"." },
|
|
629
|
+
refresh: { type: "boolean", default: false, description: "Regenerate the cache (ai/platform/engine-capability-catalog.json) from a fresh source scan instead of reading the cached one." },
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
handler: async (args) => {
|
|
633
|
+
if (args.query) return searchEngineCatalog(args.query, { refresh: !!args.refresh });
|
|
634
|
+
const catalog = buildEngineCatalog({ refresh: !!args.refresh });
|
|
635
|
+
if (args.engine) {
|
|
636
|
+
const e = catalog.engines[args.engine];
|
|
637
|
+
if (!e) throw new Error(`unknown engine "${args.engine}". Known: ${Object.keys(catalog.engines).join(", ")}`);
|
|
638
|
+
return e;
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
generatedAt: catalog.generatedAt,
|
|
642
|
+
engineCount: catalog.engineCount,
|
|
643
|
+
engines: Object.fromEntries(Object.entries(catalog.engines).map(([name, e]) => [name, { classCount: e.classCount, restMappingCount: e.controllerBasePaths.length, migrationCount: e.ownedMigrations.length + e.relatedSharedMigrations.length }])),
|
|
644
|
+
};
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
name: "erp_platform_describe",
|
|
649
|
+
description: "Real method signatures/field lists for a CURATED set of genuinely-reusable plugin extension-point/gateway-client classes (every *Contribution interface in engine-plugin-api, the canonical WorkflowGatewayClient + its real response records InstanceView/InstanceHumanTask/HistoryEntry/PendingHumanTask/DecidedCount, every per-plugin DmsGatewayClient/WorkflowGatewayClient/CommunicationGatewayClient copy under backend/modules) — the concrete answer to 'what does PendingHumanTask actually contain' or 'what does DmsGatewayClient.uploadFile take' WITHOUT opening the Java source. Matches by partial, case-insensitive name; a name matching multiple per-plugin copies returns all of them (they're hand-copied, not shared, and may have drifted — each result is tagged with its owning file). Not exhaustive over the whole repo — only this curated set; use erp_platform_catalog first to check whether a class exists at all.",
|
|
650
|
+
inputSchema: {
|
|
651
|
+
type: "object",
|
|
652
|
+
required: ["name"],
|
|
653
|
+
properties: {
|
|
654
|
+
name: { type: "string", description: "Partial or full class/interface/record name, e.g. \"PendingHumanTask\" or \"DmsGatewayClient\"." },
|
|
655
|
+
refresh: { type: "boolean", default: false, description: "Regenerate the underlying catalog cache from a fresh source scan first." },
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
handler: async (args) => {
|
|
659
|
+
const matches = describeContract(args.name, { refresh: !!args.refresh });
|
|
660
|
+
if (!matches.length) {
|
|
661
|
+
return { name: args.name, matches: [], note: "No match in the curated extension-point/gateway-client set. Try erp_platform_catalog with `query` to search the full class inventory instead." };
|
|
662
|
+
}
|
|
663
|
+
return { name: args.name, matches };
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
name: "erp_plugin_scaffold",
|
|
668
|
+
description: "Emits a minimal, correctly-typed Java skeleton for one curated plugin extension point, pulled from a REAL existing shipped implementation in this codebase (not invented syntax) — currently: rest-contribution (modeled on backend/modules/hello-plugin's real HelloRestContribution.java). Use this instead of hand-writing an extension-point class from a guess.",
|
|
669
|
+
inputSchema: {
|
|
670
|
+
type: "object",
|
|
671
|
+
required: ["extensionPoint"],
|
|
672
|
+
properties: {
|
|
673
|
+
extensionPoint: { type: "string", enum: Object.keys(SCAFFOLD_TEMPLATES) },
|
|
674
|
+
packageName: { type: "string", description: "Java package for the scaffolded class, e.g. \"com.example.myplugin\"." },
|
|
675
|
+
className: { type: "string", description: "Class name for the scaffolded class, e.g. \"MyRestContribution\"." },
|
|
676
|
+
pluginId: { type: "string", description: "Plugin id used in the REST base path (/api/v1/plugins/<pluginId>)." },
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
handler: async (args) => {
|
|
680
|
+
const tpl = SCAFFOLD_TEMPLATES[args.extensionPoint];
|
|
681
|
+
if (!tpl) return { error: `unknown extensionPoint "${args.extensionPoint}". Known: ${Object.keys(SCAFFOLD_TEMPLATES).join(", ")}` };
|
|
682
|
+
const pluginId = args.pluginId || "my-plugin";
|
|
683
|
+
const packageName = args.packageName || `com.example.${pluginId.replace(/[^a-zA-Z0-9]/g, "")}`;
|
|
684
|
+
const className = args.className || "MyRestContribution";
|
|
685
|
+
return { extensionPoint: args.extensionPoint, describe: tpl.describe, packageName, className, pluginId, source: tpl.render({ packageName, className, pluginId }) };
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
name: "erp_service_plugin_scaffold",
|
|
690
|
+
description: "Generate a REAL, runnable Service-mode plugin starter project (Python/FastAPI or Node.js/Express) on disk — wraps the actual `platform-cli service-plugin:scaffold` command/scaffoldServicePlugin function, not invented syntax. A Service-mode plugin is a standalone process (own Dockerfile, own launcher script) that self-registers/heartbeats against engine-api via POST/DELETE /api/v1/platform/service-registrations — it is NEVER PF4J-loaded/hot-installed the way a `.spk` Java plugin is, so use erp_plugin_scaffold instead for a Java extension-point class. Writes real files to `out` and returns the actual list written (no fabricated success).",
|
|
691
|
+
inputSchema: {
|
|
692
|
+
type: "object",
|
|
693
|
+
required: ["id", "runtime", "out"],
|
|
694
|
+
properties: {
|
|
695
|
+
id: { type: "string", description: "Plugin id, e.g. \"acme-inventory-sync\" — used as plugin.json's own id and the self-registration applicationCode." },
|
|
696
|
+
runtime: { type: "string", enum: ["python", "nodejs"], description: "\"python\" generates a FastAPI project; \"nodejs\" generates an Express/TypeScript project." },
|
|
697
|
+
out: { type: "string", description: "Directory to scaffold the project into, absolute or repo-root-relative. Created if it doesn't exist." },
|
|
698
|
+
vendor: { type: "string", description: "Vendor name for plugin.json. Defaults to \"Unknown Vendor\"." },
|
|
699
|
+
port: { type: "integer", description: "Port the scaffolded process listens on and self-registers with. Defaults to 8100." },
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
handler: async (args) => serviceScaffoldTool(args),
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
name: "erp_list_grammar_rules",
|
|
706
|
+
description: "List the named RULE-xxx grammar rules this platform's real validators actually enforce today (page/block/workflow structural rules, plus documented-but-not-yet-automated conventions) — grounds 'is this allowed' in a concrete, cited enforcement point instead of a guess.",
|
|
707
|
+
inputSchema: { type: "object", properties: {} },
|
|
708
|
+
handler: async () => GRAMMAR_RULES,
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
name: "erp_search_examples",
|
|
712
|
+
description: "Find real, already-shipped example metadata JSON files (page/workflow/entities/provider) across every backend/modules/* plugin whose filename or plugin name matches a substring — grounds 'how do I author X' in a genuine precedent instead of an invented shape.",
|
|
713
|
+
inputSchema: {
|
|
714
|
+
type: "object",
|
|
715
|
+
required: ["pattern"],
|
|
716
|
+
properties: {
|
|
717
|
+
pattern: { type: "string" },
|
|
718
|
+
kind: { type: "string", enum: ["page", "workflow", "entities", "provider", "data-service", "data-view", "rules", "menu"], default: "page" },
|
|
719
|
+
maxResults: { type: "integer", default: 10 },
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
handler: async (args) => searchExamplesTool(args.pattern, args.kind, args.maxResults),
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
name: "erp_examples_patterns",
|
|
726
|
+
description: "The curated catalog of named SHAPES (workflow/page/entities), not just filenames — each entry names a recurring authoring problem (e.g. 'mark a foreign-key field for automatic id->display-name resolution', 'related-record-lookup-field', 'multi-stage-conditional-branching') and points at a real shipped file confirmed to have that shape. Use this before erp_search_examples when you know WHAT you're trying to build but not which existing plugin already solved it. Omit kind to list every catalog entry.",
|
|
727
|
+
inputSchema: {
|
|
728
|
+
type: "object",
|
|
729
|
+
properties: {
|
|
730
|
+
kind: { type: "string", enum: Object.keys(EXAMPLE_PATTERNS), description: "Omit to list all kinds." },
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
handler: async (args) => examplesPatternsTool(args.kind),
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
name: "erp_list_blocks",
|
|
737
|
+
description: "The real widget/block catalog (~124 registered types — core.grid, core.lookup, core.date-picker, core.file-upload, etc.), derived live from @erp/block-engine's own registry — every property/event/output any block type actually accepts. Closes the real gap where a developer building a page purely through this CLI/MCP had no way to discover 'what widgets exist and what can each one do' without already knowing a widget's name (for erp_search_examples) or reading block-engine source directly. Omit both filters to list every type (summary only); pass type for a single type's FULL definition (every designer group/labelKey too).",
|
|
738
|
+
inputSchema: {
|
|
739
|
+
type: "object",
|
|
740
|
+
properties: {
|
|
741
|
+
type: { type: "string", description: "Exact or partial blockType to filter/fetch, e.g. \"core.lookup\" or \"lookup\"." },
|
|
742
|
+
category: { type: "string", description: "Filter by designer category, e.g. \"input\", \"display\", \"layout\", \"data\", \"navigation\"." },
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
handler: async (args) => listBlocksTool(args.type, args.category),
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
name: "erp_tail_logs",
|
|
749
|
+
description: "Real engine-api server-side log lines, including full stack traces — closes the real gap where an AI driving purely through MCP had no way to see WHY a call failed beyond its HTTP response body. Every real API response carries a real X-Correlation-Id header matching this log's own [cid=...] token on every line for that request — pass it as `grep` after a failed call to see that exact request's real server-side trace.",
|
|
750
|
+
inputSchema: {
|
|
751
|
+
type: "object",
|
|
752
|
+
properties: {
|
|
753
|
+
lines: { type: "integer", default: 200, description: "Max matching lines to return (most recent last)." },
|
|
754
|
+
grep: { type: "string", description: "Substring filter — typically a request's X-Correlation-Id, or an exception class name." },
|
|
755
|
+
file: { type: "string", description: "Override log file path; defaults to engine-api's own logs/engine-api.log." },
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
handler: async (args) => tailLogsTool(args.lines ?? 200, args.grep, args.file),
|
|
759
|
+
},
|
|
760
|
+
];
|
|
761
|
+
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
// MCP stdio JSON-RPC transport — minimal, spec-shaped, no SDK dependency.
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
const SERVER_INFO = { name: "erp-plugin-language", version: "0.1.0" };
|
|
766
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
767
|
+
|
|
768
|
+
function send(msg) {
|
|
769
|
+
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function toolResultEnvelope(value) {
|
|
773
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function handleRequest(req) {
|
|
777
|
+
const { id, method, params } = req;
|
|
778
|
+
try {
|
|
779
|
+
if (method === "initialize") {
|
|
780
|
+
send({ jsonrpc: "2.0", id, result: { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO } });
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (method === "notifications/initialized") {
|
|
784
|
+
return; // notification, no response
|
|
785
|
+
}
|
|
786
|
+
if (method === "tools/list") {
|
|
787
|
+
send({ jsonrpc: "2.0", id, result: { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) } });
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (method === "tools/call") {
|
|
791
|
+
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
792
|
+
if (!tool) throw new Error(`unknown tool "${params?.name}"`);
|
|
793
|
+
const result = await tool.handler(params?.arguments ?? {});
|
|
794
|
+
send({ jsonrpc: "2.0", id, result: toolResultEnvelope(result) });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (id !== undefined) {
|
|
798
|
+
send({ jsonrpc: "2.0", id, error: { code: -32601, message: `method not found: ${method}` } });
|
|
799
|
+
}
|
|
800
|
+
} catch (err) {
|
|
801
|
+
if (id !== undefined) {
|
|
802
|
+
send({ jsonrpc: "2.0", id, error: { code: -32000, message: err.message } });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
808
|
+
rl.on("line", (line) => {
|
|
809
|
+
const trimmed = line.trim();
|
|
810
|
+
if (!trimmed) return;
|
|
811
|
+
let req;
|
|
812
|
+
try {
|
|
813
|
+
req = JSON.parse(trimmed);
|
|
814
|
+
} catch {
|
|
815
|
+
return; // ignore non-JSON noise on stdin
|
|
816
|
+
}
|
|
817
|
+
handleRequest(req);
|
|
818
|
+
});
|