@roottale/cms-mcp 0.34.0 → 0.34.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @roottale/cms-mcp
2
2
 
3
+ ## 0.34.1
4
+
5
+ ### Patch Changes
6
+
7
+ - ff6eec1: docs: 콘텐츠 유형(Collections) 문서를 M4 최종 모델로 갱신
8
+
9
+ 섹션은 글의 `collection_key`로 정해지고 카테고리는 섹션 안의 주제(아카이브)라는
10
+ 최종 모델 반영. catch-all·first-wins·"카테고리로 섹션 추론" 등 제거된 동작 설명을
11
+ 삭제. collections.md 전면 개정 + api-reference 에 `collection_key` 필드 + seo.md
12
+ 교차링크 정정.
13
+
3
14
  ## 0.34.0
4
15
 
5
16
  ### Patch Changes
package/dist/index.js CHANGED
@@ -279,7 +279,7 @@ function registerTools(server2) {
279
279
  }
280
280
 
281
281
  // src/server.ts
282
- var VERSION = true ? "0.34.0" : "dev";
282
+ var VERSION = true ? "0.34.1" : "dev";
283
283
  var SERVER_INSTRUCTIONS = `
284
284
  roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uAE30 \uC704\uD55C
285
285
  \uD1B5\uD569 \uBB38\uC11C\xB7\uC608\uC2DC \uCF54\uB4DC\xB7\uACF5\uAC1C API \uC870\uD68C tool\uC744 \uC81C\uACF5\uD569\uB2C8\uB2E4.
@@ -25,6 +25,7 @@ JS/TS 스택은 raw 호출 대신 `@roottale/cms-client`를 사용하세요 —
25
25
  ```json
26
26
  {
27
27
  "items": [ { "id": "…", "slug": "…", "title": "…", "body_json": {…},
28
+ "collection_key": "blog",
28
29
  "terms": [{ "taxonomy": "category", "name": "…", "slug": "…" }],
29
30
  "published_at": "…" } ],
30
31
  "has_more": false,
@@ -32,6 +33,10 @@ JS/TS 스택은 raw 호출 대신 `@roottale/cms-client`를 사용하세요 —
32
33
  }
33
34
  ```
34
35
 
36
+ > `collection_key` = 글이 속한 섹션(콘텐츠 유형). 섹션 라우팅(`/notice`·`/blog`)의 근거이며,
37
+ > 미설정이면 `null`. 카테고리(`terms`)는 섹션 안의 주제로 아카이브에만 쓰입니다 →
38
+ > [콘텐츠 유형 (Collections)](./collections.md).
39
+
35
40
  ## GET /v1/cms/public/posts/{identifier}
36
41
 
37
42
  글 1개 — `identifier`는 slug 또는 UUID. 미발행/없는 글은 `404`.
@@ -1,78 +1,95 @@
1
1
  ---
2
2
  title: 콘텐츠 유형 (Collections) — 공지·블로그 URL 분리
3
- description: 같은 글 풀을 공지 게시판(/notice)·블로그(/blog) 등 여러 스트림으로 나누는 법. URL 구조, 어드민 설정, 연동 코드, slug·301·OG.
3
+ description: 같은 글 풀을 공지 게시판(/notice)·블로그(/blog) 등 여러 섹션으로 나누는 법. 섹션=글의 collection_key, 카테고리=섹션 안의 주제. URL 구조, 어드민 설정, 연동 코드, slug·301·OG.
4
4
  ---
5
5
 
6
6
  # 콘텐츠 유형 (Collections)
7
7
 
8
- 하나의 글 목록을 **여러 스트림**으로 나눠 서로 다른 URL·레이아웃으로 보여주는 기능입니다.
9
- 가장 흔한 형태는 **공지 게시판(`/notice`) + 블로그(`/blog`)** 분리입니다. 어느 글이 어느
10
- 스트림에 속할지는 그 글의 **카테고리**로 정해집니다.
8
+ 하나의 글 목록을 **여러 섹션(stream)**으로 나눠 서로 다른 URL·레이아웃으로 보여주는
9
+ 기능입니다. 가장 흔한 형태는 **공지 게시판(`/notice`) + 블로그(`/blog`)** 분리입니다.
10
+
11
+ > **핵심 모델 (2024 ADR-0060 Amendment 1):**
12
+ > 글은 **하나의 섹션**에 속하고(`collection_key`), 그 안에서 여러 **주제**(category)를
13
+ > 가질 수 있습니다. 섹션은 글쓰기 화면의 **"어디에 올릴까요?"**에서 고르고, 주제는
14
+ > 섹션 안의 세부 분류(예: 블로그의 *칼럼*·*소식*)입니다.
11
15
 
12
16
  ## URL이 어떻게 정해지나
13
17
 
14
- > **글의 URL = 그 글이 속한 스트림의 `basePath` + `/{slug}`**
18
+ > **글의 URL = 그 글이 속한 섹션의 `basePath` + `/{slug}`** — 섹션은 글의 `collection_key`로 정해집니다.
15
19
 
16
- | 글의 카테고리 | 속하는 스트림 | URL |
20
+ | 글의 섹션 (`collection_key`) | basePath | URL |
17
21
  |---|---|---|
18
- | `notice` | 공지 (basePath `/notice`) | `/notice/{slug}` |
19
- | `column` · `news` | 블로그 (basePath `/blog`) | `/blog/{slug}` |
22
+ | `notice` (공지) | `/notice` | `/notice/{slug}` |
23
+ | `blog` (블로그) | `/blog` | `/blog/{slug}` |
20
24
 
21
- 예: `notice` 카테고리 글 "개강안내" → `https://내사이트/notice/개강안내`
22
- `column` 카테고리 글 "비문학공부법" → `https://내사이트/blog/비문학공부법`
25
+ 예: 섹션이 `notice`인 글 "개강안내" → `https://내사이트/notice/개강안내`
26
+ 섹션이 `blog`인 글 "비문학공부법" → `https://내사이트/blog/비문학공부법`
23
27
 
24
- 스트림별로 함께 만들어지는 경로:
28
+ 섹션별로 함께 만들어지는 경로:
25
29
 
26
30
  | 경로 | 설명 |
27
31
  |---|---|
28
- | `{basePath}` | 스트림 목록 (예: `/notice`, `/blog`) |
32
+ | `{basePath}` | 섹션 목록 (예: `/notice`, `/blog`) |
29
33
  | `{basePath}/{slug}` | 글 상세 |
30
- | `{basePath}/categories/{slug}` | 카테고리 아카이브 (`archives` 켠 스트림만) |
31
- | `/feed.xml` | RSS — `feed` 켠 스트림들의 통합 피드 |
32
- | `/sitemap.xml` | 글마다 **소속 스트림 basePath로** 정확히 매핑 |
34
+ | `{basePath}/categories/{slug}` | **주제** 아카이브 (`archives` 켠 섹션만) |
35
+ | `/feed.xml` | RSS — `feed` 켠 섹션들의 통합 피드 |
36
+ | `/sitemap.xml` | 글마다 **소속 섹션 basePath로** 정확히 매핑 |
33
37
  | `{basePath}/{slug}/opengraph-image` | 글별 동적 OG 카드 (배선 시) |
34
38
 
35
39
  규칙:
36
40
 
37
41
  - **slug은 한글 그대로** 됩니다(예: `/blog/비문학독해`). 내부적으로 percent-encoding.
38
- - **같은 글이 두 스트림에 안 뜸**: 공지 글을 `/blog/개강안내`로 열면 404, 반대도 404(가드).
39
- - **글이 어느 스트림에도 안 속하면**(분류 전용 카테고리 등) sitemap·feed에서 제외됩니다.
40
- - 한 글이 여러 스트림 카테고리를 동시에 가지면 **선언 순서가 빠른 스트림**이 이깁니다(first-wins).
42
+ - **같은 글이 두 섹션에 안 뜸**: 공지 글을 `/blog/개강안내`로 열면 404, 반대도 404(가드).
43
+ - **섹션(`collection_key`)이 없는 글**은 sitemap·상세 라우트에서 제외됩니다.
44
+
45
+ ## 섹션 vs 주제 (자주 헷갈리는 부분)
46
+
47
+ | | 섹션 (collection) | 주제 (category) |
48
+ |---|---|---|
49
+ | 무엇 | 글이 사는 곳 (공지 / 블로그) | 섹션 안의 세부 분류 (칼럼 / 소식) |
50
+ | 글당 | **딱 하나** (배타적) | 0개 이상 (선택·복수) |
51
+ | 정하는 곳 | 글쓰기 "어디에 올릴까요?" | 글쓰기 주제 칩 / 설정 > 분류 |
52
+ | 저장 | `post.collection_key` | 글의 category terms |
53
+ | 라우팅 | basePath 결정 (`/notice`) | 아카이브만 (`/blog/categories/칼럼`) |
54
+
55
+ > 이전 버전은 *카테고리로 섹션을 추론*했지만, 지금은 섹션이 글에 **명시**됩니다.
56
+ > 카테고리는 더 이상 어느 섹션에 속하는지를 결정하지 않습니다(주제 아카이브 전용).
41
57
 
42
58
  ## 어드민에서 설정 (`mysite.roottale.com`)
43
59
 
44
- **설정 > 콘텐츠 유형** 에서 스트림을 정의합니다. 각 스트림은:
60
+ **설정 > 콘텐츠 유형** 에서 섹션을 정의합니다. 빈 상태의 "공지 + 블로그 한 번에 만들기"
61
+ 버튼으로 표준 두 섹션을 한 번에 만들 수 있습니다. 각 섹션은:
45
62
 
46
63
  | 항목 | 의미 |
47
64
  |---|---|
48
- | key | 안정 식별자 (`notice`, `blog`) |
65
+ | key | 안정 식별자 (`notice`, `blog`) — 사이트 코드와 맞물리므로 보통 고정 |
49
66
  | 라벨 | 메뉴·작성 화면 표시 이름 (공지/블로그) |
50
67
  | basePath | URL 앞부분 (`/notice`, `/blog`) |
51
- | 카테고리 | 이 스트림에 속하는 카테고리 slug들. **비우면 catch-all**(나머지 전부) |
52
- | feed / archives / og | RSS 포함 / 카테고리 아카이브 / 동적 OG |
53
- | 순서 | 위에서부터 우선순위 |
68
+ | 주제 | 이 섹션이 제공하는 카테고리(아카이브 범위). 비우면 주제 없음 |
69
+ | feed / archives / og | RSS 포함 / 주제 아카이브 / 동적 OG |
70
+ | 순서 | 메뉴·표시 순서 |
54
71
 
55
- **비우면 단일 블로그(`/blog`)** 로 동작합니다(설정 전 기본값).
72
+ **섹션을 하나도 안 만들면 단일 블로그(`/blog`)** 로 동작합니다(설정 전 기본값).
56
73
 
57
74
  ### ⚠️ basePath는 "라우트가 있어야" 동작합니다 (데이터=DB, 라우트=코드)
58
75
 
59
- basePath·라벨·카테고리·플래그는 어드민에서 바꾸면 sitemap·feed·라우팅이 즉시 따라갑니다.
76
+ basePath·라벨·주제·플래그는 어드민에서 바꾸면 sitemap·feed·라우팅이 즉시 따라갑니다.
60
77
  **단 basePath에 해당하는 페이지 파일이 사이트에 있어야** 실제로 열립니다:
61
78
 
62
79
  - `/notice`·`/blog`처럼 **이미 라우트가 있는 경로**는 어드민만으로 자유롭게 편집 → 동작.
63
80
  - basePath를 **완전히 새 경로**(예: `/news`)로 바꾸면 sitemap엔 `/news/{slug}`가 나가지만
64
- 사이트에 `app/news/[slug]` 라우트가 없으면 **404**. 이 경우 개발자가 라우트를 먼저 추가해야
65
- 합니다. (새 basePath도 코드 수정 없이 동작시키려면 catch-all 동적 라우트를 쓰면 됩니다 — 아래.)
81
+ 사이트에 `app/news/[slug]` 라우트가 없으면 **404**. 개발자가 라우트를 먼저 추가해야
82
+ 합니다. (새 basePath도 코드 수정 없이 동작시키려면 catch-all 동적 라우트 — 아래.)
66
83
 
67
- ### 카테고리 만들기
84
+ ### 글을 섹션에 올리기
68
85
 
69
- 각 스트림의 카테고리(`notice`, `column`, `news` 등)는 **설정 > 카테고리**(taxonomy)에서
70
- 만들고, 글 작성 화면에서 글에 붙입니다. 작성 화면에는 *"이 글은 → /notice 에 게시됩니다"*
71
- 표시가 떠서 어느 스트림으로 가는지 바로 확인됩니다.
86
+ 글쓰기 화면 맨 위 **"어디에 올릴까요?"**에서 공지/블로그 카드를 고릅니다 — 이게 글의
87
+ 섹션(`collection_key`)이 됩니다. 그 아래 **주제**는 고른 섹션의 카테고리만 보입니다
88
+ (블로그면 칼럼·소식 등). 주제는 선택이며, **설정 > 분류**(taxonomy)에서 미리 만들어 둡니다.
72
89
 
73
90
  ## 사이트 연동 코드
74
91
 
75
- 스트림 선언을 route 팩토리에 넘기면 sitemap·feed·revalidate가 거기서 파생됩니다. 두 가지
92
+ 섹션 선언을 route 팩토리에 넘기면 sitemap·feed·revalidate가 거기서 파생됩니다. 두 가지
76
93
  방식이 있습니다.
77
94
 
78
95
  ### 방식 A — 코드 상수 (간단, 고정)
@@ -81,17 +98,21 @@ basePath·라벨·카테고리·플래그는 어드민에서 바꾸면 sitemap·
81
98
  import type { RouteCollection } from "@roottale/cms-renderer-next/routes";
82
99
 
83
100
  export const COLLECTIONS: RouteCollection[] = [
84
- { key: "notice", basePath: "/notice", categories: ["notice"] },
101
+ { key: "notice", basePath: "/notice", categories: [] }, // 주제 없는 게시판
85
102
  {
86
103
  key: "blog",
87
104
  basePath: "/blog",
88
- categories: ["column", "news"], // 또는 [] = catch-all(공지 외 전부)
105
+ categories: ["column", "news"], // 이 섹션의 주제(아카이브 범위)
89
106
  feed: true,
90
107
  archives: true,
91
108
  },
92
109
  ];
93
110
  ```
94
111
 
112
+ > `categories`는 이제 **그 섹션이 제공하는 주제 목록**(아카이브 `/blog/categories/{slug}`
113
+ > 생성 범위)입니다. 섹션 소속은 글의 `collection_key`로 정해지므로, 이 배열은 라우팅
114
+ > 소유권이 아닙니다.
115
+
95
116
  ```ts
96
117
  // app/sitemap.ts
97
118
  import { createSitemap } from "@roottale/cms-renderer-next/routes";
@@ -107,7 +128,7 @@ export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: COLLEC
107
128
  ### 방식 B — 어드민에서 동적 로드 (운영자가 직접 편집)
108
129
 
109
130
  상수 대신 **어드민 "콘텐츠 유형"** 값을 `fetchCollections()`로 가져와 팩토리에 **resolver
110
- 함수**로 넘깁니다. 운영자가 어드민에서 스트림을 바꾸면 사이트가 따라갑니다.
131
+ 함수**로 넘깁니다. 운영자가 어드민에서 섹션을 바꾸면 사이트가 따라갑니다.
111
132
 
112
133
  ```ts
113
134
  import { fetchCollections } from "@roottale/cms-client/server";
@@ -131,9 +152,11 @@ export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: getCol
131
152
  응답은 `RouteCollection`과 구조 호환이라 그대로 넘길 수 있습니다. 매 요청 fetch를 피하려면
132
153
  사이트 경계에서 캐시하세요(예: Next `fetch(url, { next: { revalidate: 300 } })`).
133
154
 
134
- ### 상세 페이지 가드 (스트림 누출 차단)
155
+ ### 상세 페이지 가드 (섹션 누출 차단)
135
156
 
136
- 상세 라우트는 글이 그 스트림 소속인지 확인해 다른 스트림 글이 새는 것을 막습니다.
157
+ 상세 라우트는 글이 그 섹션 소속인지 확인해 다른 섹션 글이 새는 것을 막습니다.
158
+ `resolvePostCollection`은 글의 `collectionKey`로 섹션을 해석합니다(공개 API가 글마다
159
+ `collection_key`를 내려줍니다 → `cms-client`의 `CmsPostContent.collectionKey`).
137
160
 
138
161
  ```ts
139
162
  import { resolvePostCollection } from "@roottale/cms-renderer-next/routes";
@@ -149,7 +172,7 @@ import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
149
172
  export const POST = createRevalidateRoute({ apiKey, revalidate, collections: COLLECTIONS });
150
173
  ```
151
174
 
152
- 각 스트림 basePath(+ `archives`면 `/categories`)와 `/feed.xml`·`/sitemap.xml`·`/llms.txt`를
175
+ 각 섹션 basePath(+ `archives`면 `/categories`)와 `/feed.xml`·`/sitemap.xml`·`/llms.txt`를
153
176
  자동 무효화합니다. resolver가 실패하면 잘못된 경로를 추측하지 않고 불변 경로만 갱신하며
154
177
  응답에 `warning`을 노출합니다.
155
178
 
@@ -173,41 +196,7 @@ export default createPostOgImage(
173
196
 
174
197
  > Astro 사이트는 `@roottale/cms-renderer-astro`에서 동일한 `resolvePostCollection`/
175
198
  > `resolvePostPath`/`RouteCollection`을 import 하고 `renderBlogList({ collections })`로
176
- > 링크를 스트림별로 라우팅합니다(동등 surface).
177
-
178
- ## catch-all 스트림
179
-
180
- 블로그 카테고리가 계속 늘어나는 사이트는, 블로그를 **`categories: []`(빈 배열) = catch-all**로
181
- 두면 됩니다 — 공지로 분류되지 않은 글을 전부 흡수합니다. catch-all은 **맨 뒤**에 두세요
182
- (선언 순서가 우선순위라, 뒤에 둔 스트림은 도달 못 합니다).
183
-
184
- ```ts
185
- [
186
- { key: "notice", basePath: "/notice", categories: ["notice"] },
187
- { key: "blog", basePath: "/blog", categories: [], feed: true }, // 나머지 전부
188
- ]
189
- ```
190
-
191
- ## 명시 섹션 — `collection_key` (권장)
192
-
193
- 공개 API의 글(post) 응답에는 글이 속한 섹션을 직접 가리키는 **`collection_key`**
194
- 필드가 포함됩니다. 어드민 글쓰기에서 "어디에 올릴까요?"로 고른 섹션이 이 값으로
195
- 저장됩니다. `cms-client`의 `CmsPostContent.collectionKey`로 받습니다.
196
-
197
- `resolvePostCollection(post, collections)`는 **`collectionKey`가 있으면 그것을
198
- 우선** 사용하고(일치하는 섹션이 있을 때), 없으면 기존처럼 글의 카테고리 slug로
199
- 섹션을 파생합니다. 즉:
200
-
201
- - 신규 글: `collectionKey`로 섹션이 명확히 결정됩니다(카테고리는 순수 "주제"로만 쓰임).
202
- - 구 글/구 서버: `collection_key`가 없으므로 카테고리 파생으로 **그대로 동작**(하위호환).
203
-
204
- ```ts
205
- // 소비자 코드는 동일 — resolver가 collectionKey 를 우선 사용.
206
- const c = resolvePostCollection(post, collections);
207
- ```
208
-
209
- > 마이그레이션 중에는 두 방식이 공존합니다. `collection_key`가 채워진 글은 카테고리와
210
- > 무관하게 그 섹션으로 라우팅되고, 비어 있는 글은 카테고리로 판정됩니다.
199
+ > 링크를 섹션별로 라우팅합니다(동등 surface).
211
200
 
212
201
  ## slug 변경과 301
213
202
 
@@ -218,15 +207,16 @@ const c = resolvePostCollection(post, collections);
218
207
 
219
208
  basePath를 **코드 수정 없이 어드민에서 자유롭게** 바꾸고 싶으면, 정적 `app/notice/[slug]`
220
209
  대신 **catch-all 동적 라우트** `app/[stream]/[slug]/page.tsx`(또는 `app/[...path]`)를 두고,
221
- 그 안에서 collections를 읽어 요청 경로가 어떤 스트림 basePath인지 판정해 렌더합니다. 그러면
210
+ 그 안에서 collections를 읽어 요청 경로가 어떤 섹션 basePath인지 판정해 렌더합니다. 그러면
222
211
  어드민에서 basePath를 `/news`로 바꿔도 동작합니다. 트레이드오프: 정적 라우트보다 캐시·타입
223
- 안전성이 떨어지므로, 스트림 구조가 자주 바뀌는 사이트에만 권장합니다.
212
+ 안전성이 떨어지므로, 섹션 구조가 자주 바뀌는 사이트에만 권장합니다.
224
213
 
225
214
  ## 자주 막히는 곳
226
215
 
227
- - **글이 안 보여요** → 그 글에 스트림 카테고리(`notice`/`column`/`news` 등)가 붙어 있는지 확인.
228
- 어드민 콘텐츠 유형이 비어 있으면 사이트는 코드 기본값으로만 동작합니다.
229
- - **404가 떠요** → 공지 글을 `/blog/...`로(또는 그 반대로) 열면 가드가 막습니다. 올바른 스트림
216
+ - **글이 안 보여요** → 글에 **섹션(`collection_key`)**이 지정됐는지 확인. 글쓰기 "어디에
217
+ 올릴까요?"에서 섹션을 골라야 합니다. (섹션 없는 글은 sitemap·상세에서 제외.)
218
+ - **404가 떠요** → 공지 글을 `/blog/...`로(또는 그 반대로) 열면 가드가 막습니다. 올바른 섹션
230
219
  basePath로 접근하세요. basePath를 바꿨다면 사이트에 그 라우트 파일이 있는지 확인.
231
- - **sitemap에 글이 빠졌어요** → 그 글이 어느 스트림에도 안 속하면(상세 라우트 없는 분류 전용)
232
- 의도적으로 제외됩니다.
220
+ - **주제 아카이브가 비어요** → 그 주제(카테고리)가 섹션의 `categories`(주제 목록)에 있고
221
+ `archives`가 켜져 있는지 확인.
222
+ - **sitemap에 글이 빠졌어요** → 그 글에 섹션이 없으면 의도적으로 제외됩니다.
package/docs/seo.md CHANGED
@@ -53,10 +53,11 @@ export default createSitemap(
53
53
 
54
54
  ## 다중 스트림 (collections) — 공지·블로그 분리
55
55
 
56
- 같은 글 풀을 공지 게시판(`/notice`) + 블로그(`/blog`) 등 여러 스트림으로 나눠 서로 다른
57
- URL·레이아웃으로 보여줄 수 있습니다. URL 구조·어드민 설정·연동 코드(코드 상수 vs 어드민
58
- fetch)·가드·catch-all·동적 basePath는 별도 문서 [콘텐츠 유형 (Collections)](./collections.md)
59
- 에 정리되어 있습니다. 여기 sitemap/feed 예시도 `collections`를 넘기면 스트림별로 파생됩니다.
56
+ 같은 글 풀을 공지 게시판(`/notice`) + 블로그(`/blog`) 등 여러 섹션으로 나눠 서로 다른
57
+ URL·레이아웃으로 보여줄 수 있습니다. 섹션은 글의 `collection_key`로 정해지고, 카테고리는
58
+ 섹션 안의 주제(아카이브)입니다. URL 구조·어드민 설정·연동 코드(코드 상수 vs 어드민
59
+ fetch)·가드·동적 basePath는 별도 문서 [콘텐츠 유형 (Collections)](./collections.md)
60
+ 에 정리되어 있습니다. 여기 sitemap/feed 예시도 `collections`를 넘기면 섹션별로 파생됩니다.
60
61
 
61
62
  ## robots.txt
62
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-mcp",
3
- "version": "0.34.0",
3
+ "version": "0.34.1",
4
4
  "type": "module",
5
5
  "description": "RootTale CMS integration MCP server — bundled integration docs, Next.js example code, and public API lookup tools. Run with: npx @roottale/cms-mcp",
6
6
  "bin": {