@infomind-ux/infoux-mcp 0.2.1 → 0.4.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/README.md CHANGED
@@ -9,16 +9,29 @@ INFOMIND UX팀의 퍼블리싱 기준(infoUX)을 MCP로 제공한다. 팀원이
9
9
  | 도구 | 용도 |
10
10
  |---|---|
11
11
  | `get_contract` | infoUX 작업 컨트랙트 전문. UI 작업 시작 전에 한 번 읽는다 |
12
- | `list_components` | 컴포넌트 카탈로그 29종 |
12
+ | `list_components` | 컴포넌트 카탈로그 (KRDS 28종 + infoUX 확장 — 푸터·모바일 메뉴·공지 띠·오류 페이지 등) |
13
13
  | `get_component` | 컴포넌트 마크업 스니펫 + 접근성 요건 |
14
+ | `list_icons` | 아이콘 찾기. `query` 없이 부르면 세트 목록, 주면 구글 Material Symbols 전량을 한국어·영어로 찾아 「세트에 있음」과 「카탈로그에만 있음」으로 나눠 준다 |
15
+ | `get_icon` | 아이콘 마크업·표정·접근성 요건. 구글 이름(`shopping_cart`)으로 물어도 세트의 이름을 알려 준다. 카탈로그에만 있는 아이콘이면 마크업 대신 채택 방법을 준다 |
14
16
  | `get_tokens` | 색상·폰트·브레이크포인트 토큰. `query`로 필터, `raw`로 tokens.css 원본 |
15
- | `get_rules` | 코딩 규칙 R-01~R-22 (위반·준수 예시 포함) |
17
+ | `get_rules` | 코딩 규칙 R-01~R-27 (위반·준수 예시 포함) |
16
18
  | `get_reference` | 접근성·금지패턴·Tailwind 매핑·HTML 시맨틱·사이트 유형 프로필 |
17
19
  | `get_profile` | 사이트 유형 프리셋 — section 흐름·우선 컴포넌트·밀도·표현 등급 |
18
20
  | `get_workflow` | 작업 절차 — 페이지·폼·위젯 설계, 컴포넌트 생성, 토큰 변경, UI 리뷰, 프로젝트 초기화 |
19
21
  | `get_art_direction` | 프로필별 아트 디렉션 — 표현 등급 상세·타이포 페어링·팔레트 프리셋·한글 조판·안티패턴 색인 |
20
22
  | `search_docs` | 어느 문서를 봐야 할지 모를 때 전체 검색 |
21
23
 
24
+ ### 아이콘: 세트와 카탈로그
25
+
26
+ **세트**는 우리가 채택한 아이콘(번호가 붙어 바로 쓴다), **카탈로그**는 구글 Material Symbols 전량(3,900여 종, 한국어 이름·검색어 포함)이다.
27
+ 카탈로그에만 있는 아이콘은 코드포인트·스프라이트가 없어 **채택하기 전에는 쓸 수 없다** — 그래서 `get_icon`은 마크업을 주지 않고
28
+ 채택 방법(스튜디오 「세트에 넣기」 또는 `npm run icons:adopt`)을 알려 준다. 에이전트가 없는 이름으로 마크업을 쓰면
29
+ 화면에 아무것도 안 나오고, SVG를 직접 붙이면 R-27 위반이다.
30
+
31
+ 검색 코드(`bin/icon-search.js`)는 아이콘 스튜디오와 같은 파일의 복사본이다 — 사람이 찾는 결과와 AI가 찾는 결과가 같다.
32
+ 카탈로그 색인(`data/icon-library.json`)은 영문 태그를 포함해 약 2MB(압축 후 약 0.6MB)다. 태그를 빼면 영어 동의어
33
+ (`purchase`·`padlock`·`envelope`)로는 찾을 수 없어 에이전트가 「없다」고 답하게 되므로 싣는다.
34
+
22
35
  서버는 접속 시 **지시문**도 함께 넘긴다 — 사이트 유형 판정, 토큰 강제, 카탈로그 우선, 규칙 준수 순서가 에이전트에 자동으로 걸린다.
23
36
 
24
37
  ## 설치
@@ -0,0 +1,132 @@
1
+ // 아이콘 카탈로그 검색 — 스튜디오 서버·CLI·MCP가 같은 점수를 쓴다.
2
+ //
3
+ // 이 파일은 mcp/bin/icon-search.js로 복사되어 나간다(npm run build:mcp). MCP 패키지는
4
+ // 이 저장소의 scripts/를 볼 수 없어서다. 둘이 어긋나면 AI가 찾는 결과와 사람이 스튜디오에서
5
+ // 찾는 결과가 달라진다 — check-harness가 두 파일이 같은지 지킨다. 고칠 때는 여기서만 고친다.
6
+ //
7
+ // 의존성이 없다. 색인(library.json)의 한 줄은 {m, n, c, p, ko, k, t} 꼴이다.
8
+ // m 구글 이름 n 우리 기본 이름(없으면 null) c 분류 p 인기도
9
+ // ko 한국어 이름 k 한국어 검색어 t 영문 태그
10
+
11
+ /** 칸마다 걸린 정도. 정확히 맞을수록, 한국어 이름에 맞을수록 높다. */
12
+ const W = {
13
+ koExact: 100, koStart: 80, koIncl: 60,
14
+ kExact: 70, kStart: 50, kIncl: 30,
15
+ nameExact: 90, nameSeg: 60, nameIncl: 30,
16
+ tagExact: 20, tagIncl: 8,
17
+ catIncl: 10
18
+ }
19
+
20
+ /**
21
+ * 걸린 정도를 세 등급으로 묶는다. 등급이 다르면 무조건 높은 쪽이 앞이고,
22
+ * 같은 등급 안에서는 많이 쓰이는 쪽이 앞이다.
23
+ *
24
+ * 왜 이렇게 나누나: 「돋보기」를 치면 사람은 검색 아이콘을 찾는다. 그런데 「돋보기 도킹」이라는
25
+ * 이름의 아이콘은 이름이 「돋보기」로 시작해서 80점, 검색은 검색어 사전에서 정확히 맞아 70점이었다.
26
+ * 점수만 보면 인기 없는 쪽이 앞선다. 둘은 같은 등급(잘 맞는다)이므로 인기로 가려야 한다.
27
+ * 반대로 한국어 이름이 정확히 같은 것(장바구니)은 인기와 무관하게 맨 위여야 한다.
28
+ */
29
+ const TIER = { exact: 90, good: 50, weak: 30 }
30
+
31
+ function tierOf(avg) {
32
+ if (avg >= TIER.exact) return 3
33
+ if (avg >= TIER.good) return 2
34
+ if (avg >= TIER.weak) return 1
35
+ return 0
36
+ }
37
+
38
+ const norm = (s) => String(s || '').toLowerCase().trim()
39
+
40
+ /**
41
+ * 아이콘을 찾는 자리에서 「아이콘」은 아무것도 가르지 않는다. 그런데 구글 태그에는 「find icon」처럼
42
+ * 「icon」이 박힌 것이 많아, 「cart icon」이라고 치면 장바구니와 상관없는 아이콘이 끼어든다.
43
+ */
44
+ const NOISE_TERMS = new Set(['icon', 'icons', '아이콘'])
45
+
46
+ /** 검색어 한 마디가 아이콘 한 개에 얼마나 걸리나. 안 걸리면 0. */
47
+ function scoreTerm(icon, term, categoryLabel) {
48
+ let best = 0
49
+ let via = ''
50
+ const hit = (score, reason) => { if (score > best) { best = score; via = reason } }
51
+
52
+ // 이름(영어) — 구글 이름과 우리 이름 둘 다
53
+ for (const raw of [icon.m, icon.n]) {
54
+ const name = norm(raw)
55
+ if (!name) continue
56
+ const flat = name.replace(/[-_]/g, ' ')
57
+ if (name === term || flat === term) hit(W.nameExact, 'name')
58
+ else if (flat.split(' ').includes(term)) hit(W.nameSeg, 'name')
59
+ else if (flat.includes(term)) hit(W.nameIncl, 'name')
60
+ }
61
+
62
+ // 한국어 이름과 검색어
63
+ const ko = norm(icon.ko)
64
+ if (ko) {
65
+ if (ko === term) hit(W.koExact, 'ko')
66
+ else if (ko.startsWith(term)) hit(W.koStart, 'ko')
67
+ else if (ko.includes(term)) hit(W.koIncl, 'ko')
68
+ }
69
+ for (const k of icon.k || []) {
70
+ const w = norm(k)
71
+ if (!w) continue
72
+ if (w === term) hit(W.kExact, 'k')
73
+ else if (w.startsWith(term)) hit(W.kStart, 'k')
74
+ else if (w.includes(term)) hit(W.kIncl, 'k')
75
+ }
76
+
77
+ // 영문 태그 — 이름에 없는 말로 걸릴 때(purchase → shopping_cart)
78
+ for (const t of icon.t || []) {
79
+ if (t === term) hit(W.tagExact, 'tag')
80
+ else if (term.length >= 3 && t.includes(term)) hit(W.tagIncl, 'tag')
81
+ }
82
+
83
+ // 분류 이름
84
+ if (categoryLabel && norm(categoryLabel).includes(term)) hit(W.catIncl, 'cat')
85
+
86
+ return { score: best, via }
87
+ }
88
+
89
+ /**
90
+ * 검색. 띄어 쓴 말은 모두 걸려야 한다(AND). 마디별 최고점의 평균으로 등급을 매기고,
91
+ * 같은 등급 안에서는 인기도(로그)로 가른다. 점수 = 등급 × 1000 + 평균 + 10 × log10(인기도+1).
92
+ *
93
+ * @param {{icons: object[], categories?: object[]}} library
94
+ * @param {string} query
95
+ * @param {{limit?: number, category?: string|null, only?: Set<string>|null}} opts
96
+ * only: 이 구글 이름들만 대상으로 삼는다(세트에 있는 것만 볼 때)
97
+ * @returns {{icon: object, score: number, via: string}[]}
98
+ */
99
+ function search(library, query, opts = {}) {
100
+ const { limit = 60, category = null, only = null } = opts
101
+ const terms = norm(query).split(/\s+/).filter((t) => t && !NOISE_TERMS.has(t))
102
+ const labelOf = new Map((library.categories || []).map((c) => [c.id, c.label]))
103
+
104
+ const rows = []
105
+ for (const icon of library.icons) {
106
+ if (category && icon.c !== category) continue
107
+ if (only && !only.has(icon.m)) continue
108
+
109
+ if (terms.length === 0) {
110
+ rows.push({ icon, score: 0, via: '' })
111
+ continue
112
+ }
113
+ let sum = 0
114
+ let via = ''
115
+ let ok = true
116
+ for (const term of terms) {
117
+ const r = scoreTerm(icon, term, labelOf.get(icon.c))
118
+ if (r.score === 0) { ok = false; break }
119
+ sum += r.score
120
+ if (!via) via = r.via
121
+ }
122
+ if (!ok) continue
123
+ const avg = sum / terms.length
124
+ // 인기도는 로그로 누른다 — 0~18만을 0~53으로. 등급을 넘어서 뒤집지는 못한다(등급이 ×1000)
125
+ rows.push({ icon, score: tierOf(avg) * 1000 + avg + 10 * Math.log10((icon.p || 0) + 1), via })
126
+ }
127
+
128
+ rows.sort((a, b) => b.score - a.score || (b.icon.p || 0) - (a.icon.p || 0) || a.icon.m.localeCompare(b.icon.m))
129
+ return rows.slice(0, limit)
130
+ }
131
+
132
+ module.exports = { search, scoreTerm }
package/bin/server.js CHANGED
@@ -19,6 +19,10 @@ const {
19
19
  ListToolsRequestSchema
20
20
  } = require('@modelcontextprotocol/sdk/types.js')
21
21
 
22
+ // 아이콘 검색은 아이콘 스튜디오·CLI와 같은 코드다(scripts/lib/icon-search.js의 복사본).
23
+ // 사람이 스튜디오에서 찾는 결과와 AI가 여기서 찾는 결과가 달라지지 않게 한다 — check-harness가 지킨다.
24
+ const { search: searchIcons } = require('./icon-search.js')
25
+
22
26
  const DATA_DIR = path.resolve(__dirname, '..', 'data')
23
27
 
24
28
  function readData(...segments) {
@@ -48,9 +52,15 @@ const INSTRUCTIONS = `INFOMIND UX팀의 HTML/CSS 퍼블리싱 기준(infoUX)을
48
52
  3. 컴포넌트는 카탈로그를 먼저 본다. list_components → get_component 순으로 확인하고
49
53
  기존 스니펫을 조합한다. 카탈로그 밖 컴포넌트는 임의 생성하지 않는다.
50
54
  페이지·폼·위젯 설계나 컴포넌트 신규 생성처럼 절차가 정해진 작업은 get_workflow를 먼저 읽는다.
51
- 4. 규칙 R-01~R-22를 지킨다. get_rules로 확인한다. BEM, 접근성, 금지 패턴이 여기 있다.
52
- 5. 간격·크기·타이포 스케일·반경·모션은 토큰이 아니라 CSS/Tailwind 직접값으로 쓴다.
53
- 6. 원칙이 충돌하면 get_reference("trade-off-rules")의 우선순위를 따른다. 접근성이 1순위다.
55
+ 4. 아이콘도 카탈로그에서 가져온다. list_icons(query) → get_icon(name) 순으로 확인한다.
56
+ **아이콘 이름을 지어내지 않는다** — 목록에 없는 이름을 쓰면 화면에 아무것도 안 나온다.
57
+ 구글 Material Symbols 전량을 한국어·영어로 찾을 수 있고, 결과는 둘로 나뉜다 —
58
+ 「세트에 있음」은 바로 쓰고, 「카탈로그에만 있음」은 사용자가 세트에 넣어야(채택) 쓸 수 있다.
59
+ 카탈로그에도 없으면 UX팀에 요청한다 (R-27). 세트에 없는 아이콘을 SVG로 직접 넣지 않는다.
60
+ 장식용은 aria-hidden, 의미를 담으면 role="img"+aria-label을 붙인다.
61
+ 5. 규칙 R-01~R-27을 지킨다. get_rules로 확인한다. BEM, 접근성, 금지 패턴이 여기 있다.
62
+ 6. 간격·크기·타이포 스케일·반경·모션은 토큰이 아니라 CSS/Tailwind 직접값으로 쓴다.
63
+ 7. 원칙이 충돌하면 get_reference("trade-off-rules")의 우선순위를 따른다. 접근성이 1순위다.
54
64
  화면을 마무리했으면 get_reference("release-checklist")로 점검한다 — 접근성·과업 흐름·품질은
55
65
  100% 통과가 조건이다.
56
66
 
@@ -87,6 +97,32 @@ const TOOLS = [
87
97
  required: ['name']
88
98
  }
89
99
  },
100
+ {
101
+ name: 'list_icons',
102
+ description:
103
+ '아이콘을 찾는다. 아이콘이 필요하면 **반드시 먼저 확인한다** — 목록에 없는 이름을 지어내면 ' +
104
+ '화면에 아무것도 안 나온다. query 없이 부르면 세트 목록, query를 주면 구글 Material Symbols 전량을 ' +
105
+ '한국어·영어로 찾아 「세트에 있음」(바로 쓴다)과 「카탈로그에만 있음」(채택해야 쓴다)으로 나눠 준다.',
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {
109
+ query: { type: 'string', description: '찾는 말 (예: 장바구니, 영수증, arrow, calendar). 띄어 쓰면 모두 걸려야 한다' }
110
+ }
111
+ }
112
+ },
113
+ {
114
+ name: 'get_icon',
115
+ description:
116
+ '아이콘 하나의 마크업과 접근성 요건을 반환한다. 장식용인지 의미를 담는지에 따라 ' +
117
+ 'aria 처리가 달라지므로 그대로 복사해 쓴다. 카탈로그에만 있는 아이콘이면 마크업 대신 채택 방법을 알려 준다.',
118
+ inputSchema: {
119
+ type: 'object',
120
+ properties: {
121
+ name: { type: 'string', description: '아이콘 이름 (예: search, chevron-right, calendar). 구글 이름(shopping_cart)도 받는다' }
122
+ },
123
+ required: ['name']
124
+ }
125
+ },
90
126
  {
91
127
  name: 'get_tokens',
92
128
  description:
@@ -103,7 +139,7 @@ const TOOLS = [
103
139
  {
104
140
  name: 'get_rules',
105
141
  description:
106
- 'infoUX 코딩 규칙 R-01~R-22를 반환한다. CSS·BEM·HTML·접근성 규칙과 위반 예시가 들어 있다.',
142
+ 'infoUX 코딩 규칙 R-01~R-27을 반환한다. CSS·BEM·HTML·접근성 규칙과 위반 예시가 들어 있다.',
107
143
  inputSchema: {
108
144
  type: 'object',
109
145
  properties: {
@@ -205,6 +241,306 @@ function getComponent(name) {
205
241
  return text(readData('snippets', entry.file))
206
242
  }
207
243
 
244
+ function loadIconLedger() {
245
+ try {
246
+ return JSON.parse(readData('icons.json'))
247
+ } catch {
248
+ return null
249
+ }
250
+ }
251
+
252
+ // 카탈로그 색인(구글 Material Symbols 전량). 없으면 null — 세트 목록만으로 예전처럼 답한다.
253
+ let iconLibrary
254
+ function loadIconLibrary() {
255
+ if (iconLibrary !== undefined) return iconLibrary
256
+ try {
257
+ iconLibrary = JSON.parse(readData('icon-library.json'))
258
+ } catch {
259
+ iconLibrary = null
260
+ }
261
+ return iconLibrary
262
+ }
263
+
264
+ /** 구글 이름 → 세트에서 쓰는 우리 이름. 이름이 달라져도(keyboard_arrow_up → chevron-up) 이어 준다. */
265
+ function adoptedByMaterial(ledger) {
266
+ const map = new Map()
267
+ for (const [name, meta] of Object.entries(ledger.icons)) if (meta.material) map.set(meta.material, name)
268
+ return map
269
+ }
270
+
271
+ /** 세트·카탈로그를 한꺼번에 찾아 「바로 쓴다」와 「채택해야 쓴다」로 나눠 보여 준다. */
272
+ function searchAllIcons(query, ledger, library) {
273
+ const taken = adoptedByMaterial(ledger)
274
+ const hits = searchIcons(library, query, { limit: Number.MAX_SAFE_INTEGER })
275
+
276
+ const inSet = []
277
+ const onlyCatalog = []
278
+ const seen = new Set()
279
+ for (const { icon } of hits) {
280
+ const ours = taken.get(icon.m)
281
+ if (ours) {
282
+ inSet.push({ name: ours, ko: icon.ko, material: icon.m })
283
+ seen.add(ours)
284
+ } else {
285
+ onlyCatalog.push(icon)
286
+ }
287
+ }
288
+
289
+ // 세트에는 있는데 카탈로그 색인에 없는 것(자체 제작·씨앗 밖)은 이름과 검색어로 따로 찾는다
290
+ const needle = String(query).toLowerCase()
291
+ for (const [name, meta] of Object.entries(ledger.icons)) {
292
+ if (seen.has(name) || meta.material) continue
293
+ const words = (meta.keywords || []).map(k => String(k).toLowerCase())
294
+ if (name.includes(needle) || words.some(k => k.includes(needle))) inSet.push({ name, ko: (meta.keywords || [])[0] || '' })
295
+ }
296
+
297
+ const lines = ['# infoUX 아이콘 검색', '']
298
+ lines.push(`"${query}" — 세트 ${inSet.length}종 · 카탈로그에만 ${onlyCatalog.length}종 (카탈로그 ${library.icons.length.toLocaleString('en-US')}종 가운데)`, '')
299
+
300
+ if (inSet.length > 0) {
301
+ lines.push('## 세트에 있음 — 바로 쓴다', '')
302
+ for (const i of inSet.slice(0, 30)) lines.push(`- \`${i.name}\`${i.ko ? ` — ${i.ko}` : ''}`)
303
+ if (inSet.length > 30) lines.push(`- …외 ${inSet.length - 30}종 (검색어를 좁힌다)`)
304
+ lines.push('', 'get_icon(name)으로 마크업을 가져온다.', '')
305
+ }
306
+
307
+ if (onlyCatalog.length > 0) {
308
+ lines.push('## 카탈로그에만 있음 — 채택해야 쓴다', '')
309
+ for (const i of onlyCatalog.slice(0, 20)) {
310
+ const name = i.n || (i.s ? `이름 제안 ${i.s}` : '이름을 정해야 함')
311
+ lines.push(`- \`${i.m}\`${i.ko ? ` — ${i.ko}` : ''} (${i.n ? `채택하면 ${name}` : name})`)
312
+ }
313
+ if (onlyCatalog.length > 20) lines.push(`- …외 ${onlyCatalog.length - 20}종 (검색어를 좁힌다)`)
314
+ lines.push(
315
+ '',
316
+ '이 아이콘들은 아직 세트에 없어 코드포인트·스프라이트가 없다. **지금 마크업에 쓰면 화면에 아무것도 안 나온다.**',
317
+ 'get_icon(구글 이름)이 채택 방법을 알려 준다. 사용자에게 채택을 요청하고, SVG를 직접 붙이지 않는다 (R-27).',
318
+ ''
319
+ )
320
+ }
321
+
322
+ if (inSet.length === 0 && onlyCatalog.length === 0) {
323
+ lines.push(
324
+ '세트에도 카탈로그에도 없다. **이름을 지어내지 않는다.**',
325
+ '다른 말로 한 번 더 찾아 본다(동의어·영어 이름·더 짧은 단어). 그래도 없으면 UX팀에 제작을 요청한다 (R-27).'
326
+ )
327
+ }
328
+ return text(lines.join('\n'))
329
+ }
330
+
331
+ function listIcons({ query } = {}) {
332
+ const ledger = loadIconLedger()
333
+ if (!ledger) return text('아이콘 카탈로그가 이 번들에 없다. npm run build:mcp로 다시 만든다.')
334
+
335
+ const library = loadIconLibrary()
336
+ if (query && String(query).trim() && library) return searchAllIcons(String(query).trim(), ledger, library)
337
+
338
+ const all = Object.entries(ledger.icons).map(([name, meta]) => ({ name, ...meta }))
339
+ const needle = query ? String(query).toLowerCase() : null
340
+ // 한국어로도 찾는다 — 「달력」·「즐겨찾기」·「찾아오는길」. 이름이 영어라
341
+ // 이 다리가 없으면 AI가 「없다」고 판단해 이름을 지어낸다 (R-27)
342
+ const rows = needle
343
+ ? all.filter(i =>
344
+ i.name.includes(needle) ||
345
+ (i.category || '').includes(needle) ||
346
+ (i.label || '').includes(needle) ||
347
+ (i.keywords || []).some(k => String(k).toLowerCase().includes(needle))
348
+ )
349
+ : all
350
+
351
+ if (rows.length === 0) {
352
+ return text(
353
+ `"${query}"에 해당하는 아이콘이 없다. **이름을 지어내지 말고** list_icons()로 전체 목록을 확인한다.\n` +
354
+ '필요한 아이콘이 카탈로그에 없으면 UX팀에 요청한다 (R-27).'
355
+ )
356
+ }
357
+
358
+ const byCat = new Map()
359
+ for (const r of rows) {
360
+ const c = r.category || '기타'
361
+ if (!byCat.has(c)) byCat.set(c, [])
362
+ byCat.get(c).push(r.name)
363
+ }
364
+
365
+ const withFill = all.filter((i) => (i.variants || []).includes('fill')).length
366
+ const lines = ['# infoUX 아이콘 카탈로그', '']
367
+ lines.push(query ? `"${query}" 검색 — ${rows.length}종` : `세트 ${rows.length}종.`, '')
368
+ if (needle) {
369
+ // 이름에 없는 말로 걸린 것은 왜 걸렸는지 밝힌다 — 엉뚱한 결과처럼 보이지 않게
370
+ const via = rows
371
+ .filter(i => !i.name.includes(needle))
372
+ .map(i => `${i.name}(${(i.keywords || []).find(k => String(k).toLowerCase().includes(needle))})`)
373
+ if (via.length > 0) lines.push(`검색어로 걸린 것 — ${via.join(', ')}`, '')
374
+ }
375
+ for (const [cat, names] of byCat) {
376
+ lines.push(`- **${cat}** — ${names.join(', ')}`)
377
+ }
378
+ lines.push(
379
+ '',
380
+ `표정은 슬림 · 레귤러(기본) · 볼드 · 필 네 가지다. 슬림·볼드는 전부 있고, 필은 ${withFill}종에만 있다 — ` +
381
+ '채울 면이 없는 형태(돋보기·화살표 등)에는 만들지 않는다. **어느 아이콘에 무엇이 있는지는 get_icon이 알려 준다.**',
382
+ '',
383
+ 'get_icon(name)으로 마크업을 가져온다.',
384
+ library
385
+ ? `세트에 없는 아이콘은 list_icons(query)로 카탈로그 ${library.icons.length.toLocaleString('en-US')}종에서 찾는다 — 채택하면 쓸 수 있다.`
386
+ : '',
387
+ '**목록에 없는 이름을 쓰지 않는다** — 화면에 아무것도 안 나온다. 필요하면 UX팀에 요청한다 (R-27).'
388
+ )
389
+ return text(lines.filter((l, i, a) => !(l === '' && a[i - 1] === '')).join('\n'))
390
+ }
391
+
392
+ /**
393
+ * 이 아이콘이 가진 표정만 알려 준다.
394
+ *
395
+ * 전부 나열하면 AI가 없는 표정을 골라 쓴다 — 화면에는 빈 네모가 나온다.
396
+ * 대장(icon-codepoints.json)의 variants가 정본이다.
397
+ */
398
+ function iconVariantSection(name, meta) {
399
+ const has = Array.isArray(meta.variants) ? meta.variants : []
400
+ const label = { slim: '슬림(가늘게)', bold: '볼드(강조)', fill: '필(선택·활성)' }
401
+
402
+ if (has.length === 0) {
403
+ return [
404
+ '## 표정',
405
+ '',
406
+ `이 아이콘은 기본(레귤러) 하나뿐이다. \`icon-font--slim\` 같은 표정 클래스를 붙이면 빈 네모가 나온다.`,
407
+ ''
408
+ ]
409
+ }
410
+
411
+ return [
412
+ '## 표정',
413
+ '',
414
+ `이 아이콘이 가진 표정 — 레귤러(기본) · ${has.map((v) => label[v] || v).join(' · ')}`,
415
+ '',
416
+ '```html',
417
+ ...has.map((v) => `<span class="icon-font icon-font--${v} icon-font--${name}" aria-hidden="true"></span>`),
418
+ ...has.map((v) => `<svg class="icon" aria-hidden="true"><use href="/assets/icons/sprite-${v}.svg#${name}"></use></svg>`),
419
+ '```',
420
+ '',
421
+ '폰트는 클래스가, SVG는 스프라이트 파일이 표정을 정한다 — SVG에 `icon--bold` 같은',
422
+ '클래스를 붙이지 않는다(아무 일도 하지 않는다). **여기 없는 표정은 쓰지 않는다.**',
423
+ '한 화면에서 표정을 섞지 않는다 — 굵기가 뒤섞이면 중요도가 다른 것처럼 읽힌다.',
424
+ ''
425
+ ]
426
+ }
427
+
428
+ /**
429
+ * 카탈로그에만 있는 아이콘 — 마크업을 주지 않고 채택 방법을 알려 준다.
430
+ *
431
+ * 마크업을 만들어 주면 AI는 그대로 쓴다. 그런데 이 이름의 클래스·스프라이트·코드포인트가
432
+ * 프로젝트에 없어 화면에 아무것도 안 나온다. SVG를 직접 붙이는 것도 R-27 위반이다.
433
+ */
434
+ function catalogOnlyIcon(row, library) {
435
+ const label = library.categories?.find(c => c.id === row.c)?.label
436
+ const ours = row.n || row.s
437
+ const lines = [
438
+ `# ${row.m} — 카탈로그에만 있음`,
439
+ '',
440
+ `${row.ko ? `한국어 이름 ${row.ko} · ` : ''}${label ? `분류 ${label} · ` : ''}구글 Material Symbols. **이 세트에는 아직 없다** (코드포인트·스프라이트 없음).`,
441
+ ...(row.k && row.k.length > 0 ? ['', `이 아이콘을 부르는 말 — ${row.k.join(' · ')}`] : []),
442
+ '',
443
+ '## 쓰려면 채택해야 한다',
444
+ ''
445
+ ]
446
+ if (row.n) {
447
+ lines.push(`채택하면 \`${row.n}\`(으)로 쓴다.`)
448
+ } else {
449
+ lines.push(`이름 규칙에 맞지 않아 이름을 정해야 한다 — ${row.x}.${row.s ? ` 제안: \`${row.s}\`.` : ''}`)
450
+ }
451
+ lines.push(
452
+ '',
453
+ '1. 아이콘 스튜디오 → 찾기 → 카탈로그 전체 → 이 아이콘을 눌러 「세트에 넣기」',
454
+ `2. 저장소가 있으면: \`npm run icons:adopt -- ${row.m}${row.n ? '' : ` --as ${ours || '<이름>'}`} --build\``,
455
+ `3. 채택해 저장소에 반영된 뒤에 get_icon("${ours || '<채택한 이름>'}")이 마크업을 준다.`,
456
+ '',
457
+ '**채택하기 전에는 이 이름으로 마크업을 쓰지 않는다** — 화면에 아무것도 안 나온다.',
458
+ 'SVG를 직접 붙여 넣는 것도 안 된다 (R-27). 사용자에게 채택을 요청한다.'
459
+ )
460
+ return text(lines.join('\n'))
461
+ }
462
+
463
+ function getIcon(name) {
464
+ const ledger = loadIconLedger()
465
+ if (!ledger) return text('아이콘 카탈로그가 이 번들에 없다. npm run build:mcp로 다시 만든다.')
466
+
467
+ const meta = ledger.icons[name]
468
+ if (!meta) {
469
+ const library = loadIconLibrary()
470
+ // 구글 이름(shopping_cart)이나 하이픈으로 바꾼 꼴(shopping-cart)로 물어도 알아듣는다
471
+ const key = String(name || '').trim().toLowerCase()
472
+ const row = library?.icons.find(i => i.m === key || i.m === key.replace(/-/g, '_'))
473
+ if (row) {
474
+ const ours = adoptedByMaterial(ledger).get(row.m)
475
+ if (ours) {
476
+ const inner = getIcon(ours).content[0].text
477
+ return text(`"${name}"은(는) 구글 이름이다. 이 세트에서 쓰는 이름 — \`${ours}\`\n\n${inner}`)
478
+ }
479
+ return catalogOnlyIcon(row, library)
480
+ }
481
+
482
+ // 가까운 후보 — 카탈로그까지 훑는다. 세트에 있는 것과 채택해야 하는 것을 구분해 보인다
483
+ if (library) {
484
+ const taken = adoptedByMaterial(ledger)
485
+ const label = (icon) => (taken.has(icon.m) ? `${taken.get(icon.m)}(세트)` : `${icon.m}(카탈로그에만)`)
486
+ const words = key.split(/[-_\s]+/).filter(Boolean)
487
+ // 마디를 모두 만족하는 것을 먼저, 없으면 마디 하나씩 — 철자가 틀린 이름이나 지어낸 이름도 단서를 준다
488
+ let near = searchIcons(library, words.join(' '), { limit: 8 }).map(({ icon }) => label(icon))
489
+ for (let w = 0; near.length === 0 && w < words.length; w += 1) {
490
+ near = searchIcons(library, words[w], { limit: 8 }).map(({ icon }) => label(icon))
491
+ }
492
+ if (near.length > 0) return notFound(`아이콘 "${name}"`, near)
493
+ }
494
+ const near = Object.keys(ledger.icons).filter(n => n.includes(String(name).split('-')[0])).slice(0, 8)
495
+ return notFound(`아이콘 "${name}"`, near.length > 0 ? near : Object.keys(ledger.icons).slice(0, 12))
496
+ }
497
+
498
+ const lines = [
499
+ `# ${name}`,
500
+ '',
501
+ `분류 ${meta.category} · 출처 ${meta.source} · 코드포인트 ${meta.codepoint}`,
502
+ ...(Array.isArray(meta.keywords) && meta.keywords.length > 0
503
+ ? ['', `이 아이콘을 부르는 말 — ${meta.keywords.join(' · ')}`]
504
+ : []),
505
+ '',
506
+ '## 붙여 넣을 코드 (기본 — 폰트)',
507
+ '',
508
+ '```html',
509
+ `<span class="icon-font icon-font--${name}" aria-hidden="true"></span>`,
510
+ '```',
511
+ '',
512
+ '아이콘에는 항상 aria-hidden을 붙인다. 뜻은 옆의 텍스트나 버튼의 aria-label이 전한다 —',
513
+ '폰트 아이콘은 스크린리더가 PUA 코드포인트를 엉뚱하게 읽는다.',
514
+ '',
515
+ '```html',
516
+ `<button class="btn"><span class="icon-font icon-font--${name}" aria-hidden="true"></span>검색</button>`,
517
+ `<button class="btn btn--text" aria-label="설명"><span class="icon-font icon-font--${name}" aria-hidden="true"></span></button>`,
518
+ '```',
519
+ '',
520
+ '## SVG 태그로 넣을 때',
521
+ '',
522
+ '폰트를 못 쓰는 곳이나 아이콘 하나만 색을 달리해야 할 때만 쓴다.',
523
+ '',
524
+ '```html',
525
+ `<svg class="icon" aria-hidden="true"><use href="/assets/icons/sprite.svg#${name}"></use></svg>`,
526
+ '```',
527
+ '',
528
+ ...iconVariantSection(name, meta),
529
+ '## 크기',
530
+ '',
531
+ '`.icon-font`(24) · `--xsmall`(16) · `--small`(20) · `--large`(32) · `--inherit`(글자 크기)',
532
+ 'SVG 방식은 `.icon` + 같은 어휘를 쓴다.',
533
+ '',
534
+ '## 지킬 것',
535
+ '',
536
+ '- 색을 아이콘에 넣지 않는다 — `fill: currentColor`가 부모 `color`를 따라간다 (R-01)',
537
+ '- 아이콘만 있는 버튼은 버튼에도 `aria-label`을 준다',
538
+ '- 클릭 영역은 아이콘 크기가 아니라 44×44px 이상 (R-13)',
539
+ '- 폰트로 쓸 때는 `.icon-font .icon-font--' + name + '` + `aria-hidden` + 텍스트 라벨 (여벌 경로)'
540
+ ]
541
+ return text(lines.join('\n'))
542
+ }
543
+
208
544
  function getTokens({ query, raw } = {}) {
209
545
  if (raw) return text(readData('tokens.css'))
210
546
 
@@ -533,6 +869,10 @@ server.setRequestHandler(CallToolRequestSchema, async request => {
533
869
  return listComponents()
534
870
  case 'get_component':
535
871
  return getComponent(args.name)
872
+ case 'list_icons':
873
+ return listIcons(args)
874
+ case 'get_icon':
875
+ return getIcon(args.name)
536
876
  case 'get_tokens':
537
877
  return getTokens(args)
538
878
  case 'get_rules':