@ncds/ui-admin-mcp 1.0.0-alpha.7 → 1.0.0-alpha.9

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.
Files changed (40) hide show
  1. package/bin/definitions/instructions.md +14 -2
  2. package/bin/definitions/rules.json +2 -1
  3. package/bin/server.js +39 -18
  4. package/bin/tools/renderToHtml.d.ts +3 -2
  5. package/bin/tools/renderToHtml.js +119 -126
  6. package/bin/types.d.ts +9 -0
  7. package/bin/utils/dataLoader.d.ts +1 -10
  8. package/bin/utils/response.d.ts +2 -0
  9. package/bin/utils/response.js +7 -1
  10. package/data/_tokens.json +8 -8
  11. package/data/badge-group.json +177 -3
  12. package/data/badge.json +118 -2
  13. package/data/bread-crumb.json +22 -2
  14. package/data/button.json +118 -2
  15. package/data/combo-box.json +19 -3
  16. package/data/date-picker.json +2 -1
  17. package/data/dropdown.json +93 -4
  18. package/data/empty-state.json +165 -2
  19. package/data/featured-icon.json +1 -1
  20. package/data/file-input.json +158 -4
  21. package/data/horizontal-tab.json +216 -1
  22. package/data/image-file-input.json +158 -4
  23. package/data/input-base.json +156 -2
  24. package/data/notification.json +21 -3
  25. package/data/number-input.json +156 -2
  26. package/data/password-input.json +220 -2
  27. package/data/progress-bar.json +21 -2
  28. package/data/range-date-picker-with-buttons.json +159 -6
  29. package/data/range-date-picker.json +158 -5
  30. package/data/select-box.json +19 -3
  31. package/data/select.json +19 -3
  32. package/data/spinner.json +1 -2
  33. package/data/switch.json +24 -2
  34. package/data/tag.json +59 -1
  35. package/data/vertical-tab.json +217 -2
  36. package/package.json +1 -1
  37. package/bin/instructions.d.ts +0 -1
  38. package/bin/instructions.js +0 -14
  39. package/bin/tools/getComponentHtml.d.ts +0 -3
  40. package/bin/tools/getComponentHtml.js +0 -30
@@ -5,7 +5,7 @@ You are an agent that builds UI using NCUA (NCDS UI Admin) design system compone
5
5
  ## Absolute Rules (VIOLATION = CRITICAL FAILURE)
6
6
 
7
7
  1. Call ping ONCE at the start of every session before using any other tool. This loads version info and usage rules.
8
- 2. NEVER define or guess NCUA CSS variables (--ncua-*). NCUA component styles are controlled by CDN CSS only.
8
+ 2. NEVER define or guess NCUA CSS variables (--ncua-\*). NCUA component styles are controlled by CDN CSS only.
9
9
  3. NEVER write SVG icons or icon markup manually. ALL icons MUST come from search_icon or list_icons. If you write a single svg tag by hand, you have FAILED.
10
10
  4. NEVER use emoji in generated HTML, CSS, or any output. No exceptions.
11
11
  5. You MUST generate NCUA component HTML using render_to_html or render_to_html_batch only. Never write component HTML/CSS manually.
@@ -30,20 +30,29 @@ You are an agent that builds UI using NCUA (NCDS UI Admin) design system compone
30
30
 
31
31
  ### Step 1: Component Discovery
32
32
 
33
+ - Call **list_components** first to see all available components and their categories. This prevents reinventing components that already exist.
33
34
  - Use **search_component** with Korean/English keywords to find the right component
34
35
  - Example: "비밀번호" → password-input, "모달" → modal
35
36
  - **IMPORTANT**: input is for plain text only. For passwords, always use password-input.
36
37
 
37
38
  ### Step 2: Props Check
38
39
 
39
- - Use **get_component_props** to see available properties
40
+ - Use **get_component_props** to see available properties BEFORE calling render_to_html.
40
41
  - Pass all required props. Choose enum values only from the allowed list.
42
+ - Check `type: "object"` props carefully — they often expect arrays or structured objects. Read the `rawType` field for the actual TypeScript type.
43
+ - For icon props (`leadingIcon`, `trailingIcon`, `icon`), pass `{ type: "icon", icon: "IconName" }` where IconName is a PascalCase name from search_icon.
41
44
 
42
45
  ### Step 3: HTML Generation
43
46
 
44
47
  - When building a page with multiple components, use **render_to_html_batch** to render them all in one call (max 30). This is much more efficient than calling render_to_html repeatedly.
45
48
  - For a single component, use **render_to_html**.
46
49
  - Use the returned html as-is. Do NOT modify class names, structure, or attributes.
50
+ - Check the `warnings` field in the response — it reports invalid enum values and missing required props.
51
+ - **If render returns empty or minimal HTML**, the component likely needs data props (e.g. `menus` for HorizontalTab, `items` for BreadCrumb). Check get_component_props and retry with proper data.
52
+ - **Empty result recovery** (max 3 attempts):
53
+ 1. Call get_component_props to identify required/data props
54
+ 2. Retry render_to_html with meaningful prop values
55
+ 3. If still empty after 3 attempts, report the issue to the user
47
56
 
48
57
  ### Step 4: CDN Inclusion
49
58
 
@@ -55,6 +64,9 @@ You are an agent that builds UI using NCUA (NCDS UI Admin) design system compone
55
64
 
56
65
  - Combine render_to_html results to build the final page
57
66
  - Do NOT modify NCUA component styles
67
+ - Apply spacing between components using NCUA design tokens: call **get_design_tokens** with category "spacing" to get available spacing values (e.g. var(--spacing-4), var(--spacing-8))
68
+ - When deciding component size or hierarchy, document your reasoning in an HTML comment (e.g. `<!-- size:sm chosen for compact sidebar layout -->`)
69
+ - If a commerce-rag MCP server is available, query it for page layout patterns and spacing guidelines specific to the project
58
70
 
59
71
  ## Building Custom Areas (not covered by NCUA)
60
72
 
@@ -54,5 +54,6 @@
54
54
  ],
55
55
  "category": [
56
56
  "Component categories follow the design team standard (DES INDEX): action, input, icon, overlay, navigation, feedback, data-display."
57
- ]
57
+ ],
58
+ "pingReminder": "IMPORTANT: You have NOT called ping yet. Call ping ONCE before proceeding. Rules: (1) NEVER hardcode hex/rgb colors - use get_design_tokens. (2) NEVER write SVG icons - use search_icon. (3) Use render_to_html or render_to_html_batch for all NCUA components. (4) For custom CSS, prefer NCUA tokens. Call get_design_tokens first."
58
59
  }
package/bin/server.js CHANGED
@@ -48,7 +48,7 @@ const loadToolDefinitions = (definitionsDir) => {
48
48
  }
49
49
  return { descriptions, capabilities };
50
50
  };
51
- /** rules.json을 로딩하여 flat 배열로 반환 */
51
+ /** rules.json을 로딩하여 flat 배열 + pingReminder 반환 */
52
52
  const loadRules = (definitionsDir) => {
53
53
  const raw = fs_1.default.readFileSync(path_1.default.join(definitionsDir, 'rules.json'), 'utf-8');
54
54
  const data = JSON.parse(raw);
@@ -63,22 +63,23 @@ const loadRules = (definitionsDir) => {
63
63
  'compliance',
64
64
  'category',
65
65
  ];
66
- const rules = [];
66
+ const flat = [];
67
67
  for (const key of RULE_KEYS) {
68
68
  const arr = data[key];
69
69
  if (!Array.isArray(arr)) {
70
70
  throw new Error(`rules.json에 ${key} 배열이 없습니다`);
71
71
  }
72
- rules.push(...arr);
72
+ flat.push(...arr);
73
73
  }
74
- return rules;
74
+ const pingReminder = typeof data.pingReminder === 'string' ? data.pingReminder : '';
75
+ return { flat, pingReminder };
75
76
  };
76
77
  // ── 진입점 ───────────────────────────────────────────────────────────────────
77
78
  const main = async () => {
78
79
  const definitionsDir = path_1.default.resolve(__dirname, 'definitions');
79
80
  // ── definitions/ 로딩 (main() 안에서 실행하여 catch에 포함) ──
80
81
  const { descriptions, capabilities } = loadToolDefinitions(definitionsDir);
81
- const rules = loadRules(definitionsDir);
82
+ const { flat: rules, pingReminder } = loadRules(definitionsDir);
82
83
  const instructions = (0, dataLoader_js_1.loadInstructions)(definitionsDir);
83
84
  const complianceRules = (0, dataLoader_js_1.loadComplianceRules)(definitionsDir);
84
85
  const jsApiMap = (0, dataLoader_js_1.loadJsApi)(definitionsDir);
@@ -91,6 +92,16 @@ const main = async () => {
91
92
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
92
93
  const bundle = require(bundlePath);
93
94
  logger_js_1.logger.info('컴포넌트 번들 로딩 완료');
95
+ // 아이콘 번들 로딩 — renderToHtml에서 아이콘 이름 문자열을 실제 React 컴포넌트로 resolve
96
+ let iconBundle = {};
97
+ try {
98
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
99
+ iconBundle = require('@ncds/ui-admin-icon');
100
+ logger_js_1.logger.info('아이콘 번들 로딩 완료');
101
+ }
102
+ catch {
103
+ logger_js_1.logger.warn('아이콘 번들 로딩 실패 — 아이콘 resolve 비활성화');
104
+ }
94
105
  // ── DOM + React 런타임 초기화 (유틸로 추출 § 3.9) ──
95
106
  const reactRuntime = (0, domEnvironment_js_1.setupDomEnvironment)();
96
107
  logger_js_1.logger.info('DOM + React 런타임 초기화 완료');
@@ -103,21 +114,31 @@ const main = async () => {
103
114
  const listIconsResponse = (0, listIcons_js_1.buildListIconsResponse)(iconSummary);
104
115
  // ── MCP 서버 생성 + tool 등록 ──
105
116
  const server = new mcp_js_1.McpServer({ name: 'ncds-ui-admin', version: version_js_1.VERSION }, { instructions });
106
- server.registerTool('ping', { description: descriptions['ping'] }, () => (0, ping_js_1.ping)({ componentMap, cdnMeta, iconMeta, version: version_js_1.VERSION, rules, capabilities }));
107
- server.registerTool('list_icons', { description: descriptions['list_icons'] }, () => (0, listIcons_js_1.listIcons)(listIconsResponse));
117
+ // ping 호출 추적 미호출 다른 tool 응답에 핵심 rules 주입 (세션 레벨 상태, let 의도적 사용)
118
+ let pinged = false;
119
+ const withPingReminder = (response) => {
120
+ if (pinged || !pingReminder)
121
+ return response;
122
+ return (0, response_js_1.appendPingReminder)(response, pingReminder);
123
+ };
124
+ server.registerTool('ping', { description: descriptions['ping'] }, () => {
125
+ pinged = true;
126
+ return (0, ping_js_1.ping)({ componentMap, cdnMeta, iconMeta, version: version_js_1.VERSION, rules, capabilities });
127
+ });
128
+ server.registerTool('list_icons', { description: descriptions['list_icons'] }, () => withPingReminder((0, listIcons_js_1.listIcons)(listIconsResponse)));
108
129
  server.registerTool('search_icon', {
109
130
  description: descriptions['search_icon'],
110
131
  inputSchema: { query: zod_1.z.string().describe('Search keyword (e.g. "search", "alert", "arrow")') },
111
- }, ({ query }) => (0, searchIcon_js_1.searchIcon)(iconData, query));
112
- server.registerTool('list_components', { description: descriptions['list_components'] }, () => (0, listComponents_js_1.listComponents)(listComponentsResponse));
132
+ }, ({ query }) => withPingReminder((0, searchIcon_js_1.searchIcon)(iconData, query)));
133
+ server.registerTool('list_components', { description: descriptions['list_components'] }, () => withPingReminder((0, listComponents_js_1.listComponents)(listComponentsResponse)));
113
134
  server.registerTool('search_component', {
114
135
  description: descriptions['search_component'],
115
136
  inputSchema: { query: zod_1.z.string().describe('Search keyword (Korean/English, case-insensitive)') },
116
- }, ({ query }) => (0, searchComponent_js_1.searchComponent)(componentMap, query));
137
+ }, ({ query }) => withPingReminder((0, searchComponent_js_1.searchComponent)(componentMap, query)));
117
138
  server.registerTool('get_component_props', {
118
139
  description: descriptions['get_component_props'],
119
140
  inputSchema: { name: zod_1.z.string().min(1).describe('Component name (e.g. "button", "password-input")') },
120
- }, ({ name }) => (0, getComponentProps_js_1.getComponentProps)(componentMap, name));
141
+ }, ({ name }) => withPingReminder((0, getComponentProps_js_1.getComponentProps)(componentMap, name)));
121
142
  server.registerTool('validate_html', {
122
143
  description: descriptions['validate_html'],
123
144
  inputSchema: {
@@ -126,7 +147,7 @@ const main = async () => {
126
147
  .min(1)
127
148
  .describe('HTML markup to validate (e.g. \'<button class="ncua-btn ncua-btn--primary"></button>\')'),
128
149
  },
129
- }, ({ html }) => (0, validateHtml_js_1.validateHtml)({ componentMap, rootClassMap, html, cdnMeta, tokenData, complianceRules, tokenValueMap }));
150
+ }, ({ html }) => withPingReminder((0, validateHtml_js_1.validateHtml)({ componentMap, rootClassMap, html, cdnMeta, tokenData, complianceRules, tokenValueMap })));
130
151
  server.registerTool('render_to_html', {
131
152
  description: descriptions['render_to_html'],
132
153
  inputSchema: {
@@ -136,18 +157,17 @@ const main = async () => {
136
157
  .optional()
137
158
  .describe('Component props (e.g. { label: "OK", hierarchy: "primary" })'),
138
159
  },
139
- },
140
- // MCP SDK의 zod 추론 타입이 Record<string, unknown> | undefined와 미스매치하여 as 필요
141
- ({ name, props }) => (0, renderToHtml_js_1.renderToHtml)({
160
+ }, ({ name, props }) => withPingReminder((0, renderToHtml_js_1.renderToHtml)({
142
161
  componentMap,
143
162
  bundle,
163
+ iconBundle,
144
164
  cdnMeta,
145
165
  iconMeta,
146
166
  reactRuntime,
147
167
  jsApiMap,
148
168
  name,
149
169
  props: props,
150
- }));
170
+ })));
151
171
  server.registerTool('render_to_html_batch', {
152
172
  description: descriptions['render_to_html_batch'],
153
173
  inputSchema: {
@@ -164,6 +184,7 @@ const main = async () => {
164
184
  const results = components.map(({ name, props }) => (0, renderToHtml_js_1.renderToHtml)({
165
185
  componentMap,
166
186
  bundle,
187
+ iconBundle,
167
188
  cdnMeta,
168
189
  iconMeta,
169
190
  reactRuntime,
@@ -175,7 +196,7 @@ const main = async () => {
175
196
  const text = r.content[0].text;
176
197
  return r.isError ? { error: JSON.parse(text) } : JSON.parse(text);
177
198
  });
178
- return (0, response_js_1.successResponse)(parsed);
199
+ return withPingReminder((0, response_js_1.successResponse)(parsed));
179
200
  });
180
201
  server.registerTool('get_design_tokens', {
181
202
  description: descriptions['get_design_tokens'],
@@ -185,7 +206,7 @@ const main = async () => {
185
206
  .optional()
186
207
  .describe('Token category filter (e.g. "color", "typography", "shadow", "spacing"). Omit to get all tokens.'),
187
208
  },
188
- }, ({ category }) => (0, getDesignTokens_js_1.getDesignTokens)({ tokenData, category }));
209
+ }, ({ category }) => withPingReminder((0, getDesignTokens_js_1.getDesignTokens)({ tokenData, category })));
189
210
  const transport = new stdio_js_1.StdioServerTransport();
190
211
  await server.connect(transport);
191
212
  logger_js_1.logger.info(`NCUA MCP 서버 시작됨 (ncds-ui-admin v${version_js_1.VERSION})`);
@@ -4,13 +4,14 @@
4
4
  * DOM 환경과 React/ReactDOM은 server.ts에서 초기화하고 파라미터로 전달한다.
5
5
  * 이 파일에는 fs, path, require, let이 없다.
6
6
  */
7
- import type { ComponentData, ReactRuntime } from '../types.js';
8
- import type { CdnMeta, IconMeta, JsApiInfo } from '../utils/dataLoader.js';
7
+ import type { ComponentData, ReactRuntime, JsApiInfo } from '../types.js';
8
+ import type { CdnMeta, IconMeta } from '../utils/dataLoader.js';
9
9
  import { type McpToolResponse } from '../utils/response.js';
10
10
  /** render_to_html tool 파라미터 */
11
11
  export interface RenderToHtmlParams {
12
12
  componentMap: Map<string, ComponentData>;
13
13
  bundle: Record<string, unknown>;
14
+ iconBundle: Record<string, unknown>;
14
15
  cdnMeta: CdnMeta | null;
15
16
  iconMeta: IconMeta | null;
16
17
  reactRuntime: ReactRuntime;
@@ -6,95 +6,95 @@ const response_js_1 = require("../utils/response.js");
6
6
  /** React 특수 props 차단 — injection 방지 */
7
7
  const BLOCKED_PROPS = new Set(['dangerouslySetInnerHTML', 'ref', '__self', '__source']);
8
8
  /** 아이콘 관련 props — React 변환 시 import 생성 대상 */
9
- const ICON_PROP_NAMES = new Set(['leadingIcon', 'trailingIcon', 'icon']);
10
- // ── 순수 함수 (헬퍼) ──────────────────────────────────────────────
11
- /** componentData.exportName으로 번들에서 컴포넌트를 찾는다 */
12
- const findComponent = (bundle, exportName) => {
13
- return bundle[exportName] ?? null;
14
- };
15
- /** componentMap의 props 스펙에서 defaultsUsed를 자동 계산 */
16
- const calcDefaultsUsed = (propsSpec, userProps) => {
17
- const defaults = {};
18
- for (const [key, spec] of Object.entries(propsSpec)) {
19
- if (spec.default !== undefined && !(key in userProps)) {
20
- defaults[key] = spec.default;
21
- }
22
- }
23
- return defaults;
24
- };
9
+ const ICON_PROP_NAMES = new Set(['leadingIcon', 'trailingIcon', 'icon', 'groupIcon']);
10
+ // ── 타입 가드 ──────────────────────────────────────────────────────
11
+ /** { type: 'icon', icon: string } 형태인지 판별 */
12
+ const isIconSlot = (value) => typeof value === 'object' &&
13
+ value !== null &&
14
+ value.type === 'icon' &&
15
+ typeof value.icon === 'string';
16
+ // ── Props 변환 ──────────────────────────────────────────────────────
25
17
  /** BLOCKED_PROPS를 제거 — props spec 유무와 무관하게 항상 적용 */
26
- const removeBlockedProps = (props) => {
27
- const safe = {};
28
- for (const [key, value] of Object.entries(props)) {
29
- if (!BLOCKED_PROPS.has(key))
30
- safe[key] = value;
31
- }
32
- return safe;
33
- };
18
+ const removeBlockedProps = (props) => Object.fromEntries(Object.entries(props).filter(([key]) => !BLOCKED_PROPS.has(key)));
34
19
  /** componentData.props에 정의된 키 + children만 허용하는 화이트리스트 필터 */
35
20
  const sanitizeProps = (userProps, propsSpec) => {
36
21
  const allowedKeys = new Set([...Object.keys(propsSpec), 'children']);
37
- const sanitized = {};
38
- for (const [key, value] of Object.entries(userProps)) {
39
- if (allowedKeys.has(key))
40
- sanitized[key] = value;
41
- }
42
- return sanitized;
22
+ return Object.fromEntries(Object.entries(userProps).filter(([key]) => allowedKeys.has(key)));
43
23
  };
44
- /** JSX attribute 값에서 " 이스케이프 */
45
- const escapeJsxAttrValue = (value) => value.replace(/"/g, '&quot;');
46
- /** props 값을 JSX attribute 문자열로 변환 */
47
- const propsToJsxAttrs = (props, propsSpec) => {
48
- const attrs = [];
49
- for (const [key, value] of Object.entries(props)) {
50
- if (key === 'children')
24
+ /** 아이콘 prop의 icon 문자열을 실제 React 컴포넌트로 resolve */
25
+ const resolveIconProps = (props, iconBundle) => {
26
+ const resolved = { ...props };
27
+ for (const key of ICON_PROP_NAMES) {
28
+ const iconObj = resolved[key];
29
+ if (!isIconSlot(iconObj))
51
30
  continue;
52
- const spec = propsSpec[key];
53
- if (typeof value === 'boolean' && value) {
54
- attrs.push(key);
55
- }
56
- else if (typeof value === 'string') {
57
- attrs.push(`${key}="${escapeJsxAttrValue(value)}"`);
58
- }
59
- else if (typeof value === 'number') {
60
- attrs.push(`${key}={${value}}`);
61
- }
62
- else if (ICON_PROP_NAMES.has(key) && typeof value === 'object' && value !== null) {
63
- const iconObj = value;
64
- if (iconObj.type === 'icon' && typeof iconObj.icon === 'string') {
65
- attrs.push(`${key}={{ type: 'icon', icon: ${iconObj.icon} }}`);
66
- }
67
- else {
68
- attrs.push(`${key}={${JSON.stringify(value)}}`);
69
- }
70
- }
71
- else if (spec?.type === 'enum' || spec?.type === 'string') {
72
- attrs.push(`${key}="${String(value)}"`);
73
- }
74
- else {
75
- attrs.push(`${key}={${JSON.stringify(value)}}`);
31
+ const iconComponent = iconBundle[iconObj.icon];
32
+ if (typeof iconComponent === 'function') {
33
+ resolved[key] = { ...iconObj, icon: iconComponent };
76
34
  }
77
35
  }
78
- return attrs.join(' ');
36
+ return resolved;
37
+ };
38
+ // ── JSX 변환 ──────────────────────────────────────────────────────
39
+ /** JSX attribute 값에서 " 를 이스케이프 */
40
+ const escapeJsxAttrValue = (value) => value.replace(/"/g, '&quot;');
41
+ /** 단일 prop을 JSX attribute 문자열로 변환 */
42
+ const toJsxAttr = (key, value, spec) => {
43
+ if (typeof value === 'boolean' && value)
44
+ return key;
45
+ if (typeof value === 'string')
46
+ return `${key}="${escapeJsxAttrValue(value)}"`;
47
+ if (typeof value === 'number')
48
+ return `${key}={${value}}`;
49
+ if (ICON_PROP_NAMES.has(key) && isIconSlot(value))
50
+ return `${key}={{ type: 'icon', icon: ${value.icon} }}`;
51
+ if (spec?.type === 'enum' || spec?.type === 'string')
52
+ return `${key}="${String(value)}"`;
53
+ return `${key}={${JSON.stringify(value)}}`;
54
+ };
55
+ /** props 객체를 JSX attribute 문자열로 변환 */
56
+ const propsToJsxAttrs = (props, propsSpec) => Object.entries(props)
57
+ .filter(([key]) => key !== 'children')
58
+ .map(([key, value]) => toJsxAttr(key, value, propsSpec[key]))
59
+ .join(' ');
60
+ // ── 아이콘 추출 ──────────────────────────────────────────────────
61
+ /** icon prop 값에서 아이콘 이름을 추출 */
62
+ const extractIconName = (value) => {
63
+ if (isIconSlot(value))
64
+ return value.icon;
65
+ if (typeof value === 'string' && value.length > 0)
66
+ return value;
67
+ return null;
79
68
  };
80
69
  /** appliedProps에서 아이콘 이름을 추출 (PascalCase) */
81
- const extractIconNames = (props) => {
82
- const icons = [];
83
- for (const [key, value] of Object.entries(props)) {
84
- if (!ICON_PROP_NAMES.has(key))
85
- continue;
86
- if (typeof value === 'object' && value !== null) {
87
- const iconObj = value;
88
- if (iconObj.type === 'icon' && typeof iconObj.icon === 'string') {
89
- icons.push(iconObj.icon);
90
- }
91
- }
92
- else if (typeof value === 'string' && value.length > 0) {
93
- icons.push(value);
94
- }
95
- }
96
- return icons;
70
+ const extractIconNames = (props) => Object.entries(props)
71
+ .filter(([key]) => ICON_PROP_NAMES.has(key))
72
+ .map(([, value]) => extractIconName(value))
73
+ .filter((name) => name !== null);
74
+ // ── 검증 ────────────────────────────────────────────────────────
75
+ /** 잘못된 enum 값을 경고 문자열로 변환 */
76
+ const formatInvalidEnum = (key, value, spec) => {
77
+ const allowed = spec.values ?? [];
78
+ return `Invalid enum value '${value}' for prop '${key}'. Allowed: ${allowed.join(', ')}.`;
97
79
  };
80
+ /** props 검증 — enum 불일치, 필수 prop 누락을 warnings로 반환 */
81
+ const validateProps = (userProps, propsSpec) => {
82
+ const missingRequired = Object.entries(propsSpec)
83
+ .filter(([key, spec]) => spec.required && !(key in userProps))
84
+ .map(([key]) => `Required prop '${key}' is missing.`);
85
+ const invalidEnums = Object.entries(userProps)
86
+ .filter(([key, value]) => {
87
+ const spec = propsSpec[key];
88
+ return spec?.type === 'enum' && spec.values && typeof value === 'string' && !spec.values.includes(value);
89
+ })
90
+ .map(([key, value]) => formatInvalidEnum(key, value, propsSpec[key]));
91
+ return [...missingRequired, ...invalidEnums];
92
+ };
93
+ // ── 응답 조립 ──────────────────────────────────────────────────────
94
+ /** componentMap의 props 스펙에서 defaultsUsed를 자동 계산 */
95
+ const calcDefaultsUsed = (propsSpec, userProps) => Object.fromEntries(Object.entries(propsSpec)
96
+ .filter(([key, spec]) => spec.default !== undefined && !(key in userProps))
97
+ .map(([key, spec]) => [key, spec.default]));
98
98
  /** React 변환 매핑 정보를 생성 */
99
99
  const buildReactOutput = (componentData, appliedProps, iconMeta) => {
100
100
  const { exportName, importPath, props: propsSpec } = componentData;
@@ -102,37 +102,47 @@ const buildReactOutput = (componentData, appliedProps, iconMeta) => {
102
102
  const dependencies = [importPath];
103
103
  const iconNames = extractIconNames(appliedProps);
104
104
  if (iconNames.length > 0 && iconMeta) {
105
- const iconImport = `import { ${iconNames.join(', ')} } from '${iconMeta.packageName}';`;
106
- imports.push(iconImport);
105
+ imports.push(`import { ${iconNames.join(', ')} } from '${iconMeta.packageName}';`);
107
106
  if (!dependencies.includes(iconMeta.packageName)) {
108
107
  dependencies.push(iconMeta.packageName);
109
108
  }
110
109
  }
111
110
  const attrsStr = propsSpec ? propsToJsxAttrs(appliedProps, propsSpec) : '';
111
+ const attrsPrefix = attrsStr ? ' ' + attrsStr : '';
112
112
  const children = appliedProps.children;
113
113
  const jsx = children
114
- ? `<${exportName}${attrsStr ? ' ' + attrsStr : ''}>${String(children)}</${exportName}>`
115
- : `<${exportName}${attrsStr ? ' ' + attrsStr : ''} />`;
116
- return {
117
- imports,
118
- jsx,
119
- cssImport: `import '${importPath}/style.css';`,
120
- dependencies,
121
- };
114
+ ? `<${exportName}${attrsPrefix}>${String(children)}</${exportName}>`
115
+ : `<${exportName}${attrsPrefix} />`;
116
+ return { imports, jsx, cssImport: `import '${importPath}/style.css';`, dependencies };
122
117
  };
123
118
  /** dataVersion 객체를 생성 */
124
- const buildDataVersion = (cdnMeta, iconMeta) => {
125
- const versions = {};
126
- if (cdnMeta)
127
- versions['@ncds/ui-admin'] = cdnMeta.version;
128
- if (iconMeta)
129
- versions[iconMeta.packageName] = iconMeta.version;
130
- return versions;
119
+ const buildDataVersion = (cdnMeta, iconMeta) => ({
120
+ ...(cdnMeta && { '@ncds/ui-admin': cdnMeta.version }),
121
+ ...(iconMeta && { [iconMeta.packageName]: iconMeta.version }),
122
+ });
123
+ /** jsRequired에 따라 js 필드를 생성 */
124
+ const buildJsField = (componentData, jsApiMap) => {
125
+ if (!componentData.jsRequired)
126
+ return { required: false };
127
+ const jsApi = jsApiMap.get(componentData.exportName);
128
+ return {
129
+ required: true,
130
+ description: '이 컴포넌트는 인터랙션을 위해 CDN JS가 필요합니다',
131
+ ...(jsApi && {
132
+ api: {
133
+ className: jsApi.className,
134
+ constructor: jsApi.constructor,
135
+ constructorParams: jsApi.constructorParams,
136
+ methods: jsApi.methods,
137
+ example: jsApi.example,
138
+ },
139
+ }),
140
+ };
131
141
  };
132
142
  // ── 진입점 ────────────────────────────────────────────────────────
133
143
  /** render_to_html tool — props로 React 컴포넌트를 렌더링하여 HTML + React 매핑 반환 */
134
144
  const renderToHtml = (params) => {
135
- const { componentMap, bundle, cdnMeta, iconMeta, reactRuntime, jsApiMap, name, props } = params;
145
+ const { componentMap, bundle, iconBundle, cdnMeta, iconMeta, reactRuntime, jsApiMap, name, props } = params;
136
146
  const normalized = (0, response_js_1.normalizeName)(name);
137
147
  const componentData = componentMap.get(normalized);
138
148
  if (!componentData)
@@ -141,47 +151,30 @@ const renderToHtml = (params) => {
141
151
  if (!exportName) {
142
152
  return (0, response_js_1.errorResponse)('EXPORT_NAME_MISSING', `'${normalized}' 컴포넌트에 exportName이 없습니다.`, '데이터를 재추출해주세요 (yarn extract).');
143
153
  }
144
- const Component = findComponent(bundle, exportName);
154
+ const Component = bundle[exportName] ?? null;
145
155
  if (!Component) {
146
156
  return (0, response_js_1.errorResponse)('COMPONENT_NOT_IN_BUNDLE', `'${normalized}' (${exportName}) 컴포넌트가 번들에 없습니다.`, '번들을 재빌드해주세요 (yarn build:bundle).');
147
157
  }
148
158
  try {
149
- const rawProps = props ?? {};
150
- const safeProps = removeBlockedProps(rawProps);
151
- const userProps = componentData.props ? sanitizeProps(safeProps, componentData.props) : safeProps;
152
- const element = reactRuntime.createElement(Component, userProps);
153
- const rawHtml = reactRuntime.renderToStaticMarkup(element);
159
+ const safeProps = removeBlockedProps(props ?? {});
160
+ const sanitized = componentData.props ? sanitizeProps(safeProps, componentData.props) : safeProps;
161
+ const resolvedProps = resolveIconProps(sanitized, iconBundle);
162
+ const rawHtml = reactRuntime.renderToStaticMarkup(reactRuntime.createElement(Component, resolvedProps));
154
163
  const html = `<!-- ncua:${normalized} start -->\n${rawHtml}\n<!-- ncua:${normalized} end -->`;
155
- const defaultsUsed = componentData.props ? calcDefaultsUsed(componentData.props, userProps) : {};
156
- const react = buildReactOutput(componentData, userProps, iconMeta);
157
- const dataVersion = buildDataVersion(cdnMeta, iconMeta);
158
- const jsApi = jsApiMap.get(exportName);
159
- const js = componentData.jsRequired
160
- ? {
161
- required: true,
162
- description: '이 컴포넌트는 인터랙션을 위해 CDN JS가 필요합니다',
163
- ...(jsApi && {
164
- api: {
165
- className: jsApi.className,
166
- constructor: jsApi.constructor,
167
- constructorParams: jsApi.constructorParams,
168
- methods: jsApi.methods,
169
- example: jsApi.example,
170
- },
171
- }),
172
- }
173
- : { required: false };
164
+ const warnings = componentData.props ? validateProps(sanitized, componentData.props) : [];
165
+ const hasDefaults = componentData.props ? calcDefaultsUsed(componentData.props, sanitized) : {};
174
166
  return (0, response_js_1.successResponse)({
175
167
  html,
176
168
  component: normalized,
177
169
  exportName,
178
170
  importPath: componentData.importPath,
179
- appliedProps: userProps,
180
- ...(Object.keys(defaultsUsed).length > 0 && { defaultsUsed }),
181
- js,
171
+ appliedProps: sanitized,
172
+ ...(Object.keys(hasDefaults).length > 0 && { defaultsUsed: hasDefaults }),
173
+ ...(warnings.length > 0 && { warnings }),
174
+ js: buildJsField(componentData, jsApiMap),
182
175
  cdn: cdnMeta ?? undefined,
183
- dataVersion,
184
- react,
176
+ dataVersion: buildDataVersion(cdnMeta, iconMeta),
177
+ react: buildReactOutput(componentData, sanitized, iconMeta),
185
178
  });
186
179
  }
187
180
  catch (err) {
package/bin/types.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface PropSpec {
7
7
  default?: unknown;
8
8
  values?: string[];
9
9
  rawType?: string;
10
+ properties?: Record<string, PropSpec>;
10
11
  }
11
12
  export interface ComponentUsage {
12
13
  import: string;
@@ -67,6 +68,14 @@ export interface TokenData {
67
68
  categories: string[];
68
69
  groups: TokenGroup[];
69
70
  }
71
+ export interface JsApiInfo {
72
+ className: string;
73
+ constructor: string;
74
+ constructorParams: Record<string, string>;
75
+ methods: string[];
76
+ staticMethods?: string[];
77
+ example: string;
78
+ }
70
79
  export interface ComplianceRuleHint {
71
80
  attr: string;
72
81
  values: string[];
@@ -1,4 +1,4 @@
1
- import type { ComponentData, IconData, TokenData, ComplianceRulesData } from '../types.js';
1
+ import type { ComponentData, IconData, TokenData, ComplianceRulesData, JsApiInfo } from '../types.js';
2
2
  export declare const DEFAULT_DATA_DIR: string;
3
3
  export interface CdnMeta {
4
4
  version: string;
@@ -27,15 +27,6 @@ export declare const loadTokenData: (dataDir: string) => TokenData;
27
27
  export declare const loadComplianceRules: (definitionsDir: string) => ComplianceRulesData | null;
28
28
  /** definitions/instructions.md를 로드하여 문자열 반환 */
29
29
  export declare const loadInstructions: (definitionsDir: string) => string;
30
- /** JS API 정보 타입 */
31
- export interface JsApiInfo {
32
- className: string;
33
- constructor: string;
34
- constructorParams: Record<string, string>;
35
- methods: string[];
36
- staticMethods?: string[];
37
- example: string;
38
- }
39
30
  /** definitions/js-api.json을 로드하여 exportName→JsApiInfo Map 반환 */
40
31
  export declare const loadJsApi: (definitionsDir: string) => Map<string, JsApiInfo>;
41
32
  /** componentMap에서 이름으로 단일 컴포넌트 조회 */
@@ -20,6 +20,8 @@ export declare const successResponse: (data: unknown) => McpToolResponse;
20
20
  export declare const errorResponse: (code: McpErrorCode, message: string, suggestion: string) => McpToolResponse;
21
21
  /** COMPONENT_NOT_FOUND 오류 — 여러 tool에서 공통 사용 */
22
22
  export declare const componentNotFoundResponse: (name: string) => McpToolResponse;
23
+ /** ping 미호출 시 응답에 핵심 rules를 주입하는 래퍼 */
24
+ export declare const appendPingReminder: (response: McpToolResponse, reminder: string) => McpToolResponse;
23
25
  /** 입력 정규화 — name.trim().toLowerCase() */
24
26
  export declare const normalizeName: (name: string) => string;
25
27
  /** catch 블록에서 에러 메시지를 안전하게 추출 */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.toErrorMessage = exports.normalizeName = exports.componentNotFoundResponse = exports.errorResponse = exports.successResponse = void 0;
3
+ exports.toErrorMessage = exports.normalizeName = exports.appendPingReminder = exports.componentNotFoundResponse = exports.errorResponse = exports.successResponse = void 0;
4
4
  /** 성공 응답 — data를 JSON.stringify하여 반환 */
5
5
  const successResponse = (data) => ({
6
6
  content: [{ type: 'text', text: JSON.stringify(data) }],
@@ -20,6 +20,12 @@ exports.errorResponse = errorResponse;
20
20
  /** COMPONENT_NOT_FOUND 오류 — 여러 tool에서 공통 사용 */
21
21
  const componentNotFoundResponse = (name) => (0, exports.errorResponse)('COMPONENT_NOT_FOUND', `Component '${name}' not found.`, 'Use search_component to find similar components, or list_components to see all available.');
22
22
  exports.componentNotFoundResponse = componentNotFoundResponse;
23
+ /** ping 미호출 시 응답에 핵심 rules를 주입하는 래퍼 */
24
+ const appendPingReminder = (response, reminder) => {
25
+ const text = response.content[0].text + '\n\n' + reminder;
26
+ return { ...response, content: [{ type: 'text', text }] };
27
+ };
28
+ exports.appendPingReminder = appendPingReminder;
23
29
  /** 입력 정규화 — name.trim().toLowerCase() */
24
30
  const normalizeName = (name) => name.trim().toLowerCase();
25
31
  exports.normalizeName = normalizeName;