@yoonion/mimi-seed-mcp 0.5.0 → 0.6.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.
@@ -0,0 +1,28 @@
1
+ import type { JenkinsConfig } from './config.js';
2
+ export interface JenkinsJobSummary {
3
+ name: string;
4
+ url: string;
5
+ /** 잡 상태 색상. 폴더는 색이 없다. */
6
+ color?: string;
7
+ }
8
+ /**
9
+ * 잡 목록. folder 를 주면 그 폴더 안을, 없으면 루트를 조회한다.
10
+ * 재귀하지 않는다 (한 단계만).
11
+ */
12
+ export declare function listJobs(cfg: JenkinsConfig, folder?: string): Promise<JenkinsJobSummary[]>;
13
+ /**
14
+ * 404 만 "없음" 으로 본다. 401/403/500 을 없음으로 삼키면 upsertJob 이 엉뚱하게
15
+ * 생성 분기를 타므로, 그 경우는 원인을 드러내며 throw 한다.
16
+ */
17
+ export declare function jobExists(cfg: JenkinsConfig, jobPath: string): Promise<boolean>;
18
+ /** 잡의 config.xml 원문. 백업하거나 수정 전 현재 상태를 확인할 때 쓴다. */
19
+ export declare function getJobConfig(cfg: JenkinsConfig, jobPath: string): Promise<string>;
20
+ /**
21
+ * 새 잡 생성. 같은 이름이 이미 있으면 Jenkins 가 400 을 준다.
22
+ * 폴더 안에 만들려면 jobPath 에 "folder/name" 을 넘긴다 (폴더는 미리 존재해야 함).
23
+ */
24
+ export declare function createJob(cfg: JenkinsConfig, jobPath: string, configXml: string): Promise<void>;
25
+ /** 기존 잡의 config.xml 교체. 잡이 없으면 404. */
26
+ export declare function updateJob(cfg: JenkinsConfig, jobPath: string, configXml: string): Promise<void>;
27
+ /** 있으면 수정, 없으면 생성. Jenkinsfile/job-config.xml 을 레포에서 밀어넣을 때 쓴다. */
28
+ export declare function upsertJob(cfg: JenkinsConfig, jobPath: string, configXml: string): Promise<'created' | 'updated'>;
@@ -0,0 +1,80 @@
1
+ import { authHeaders, baseUrl, createItemUrl, getCrumb, jenkinsError, jobUrl } from './http.js';
2
+ /**
3
+ * 잡 목록. folder 를 주면 그 폴더 안을, 없으면 루트를 조회한다.
4
+ * 재귀하지 않는다 (한 단계만).
5
+ */
6
+ export async function listJobs(cfg, folder) {
7
+ const root = folder ? jobUrl(cfg, folder) : baseUrl(cfg);
8
+ const res = await fetch(`${root}/api/json?tree=jobs[name,url,color]`, {
9
+ headers: authHeaders(cfg),
10
+ });
11
+ if (!res.ok)
12
+ throw await jenkinsError('Jenkins 잡 목록 조회 실패', res);
13
+ const data = (await res.json());
14
+ return data.jobs ?? [];
15
+ }
16
+ /**
17
+ * 404 만 "없음" 으로 본다. 401/403/500 을 없음으로 삼키면 upsertJob 이 엉뚱하게
18
+ * 생성 분기를 타므로, 그 경우는 원인을 드러내며 throw 한다.
19
+ */
20
+ export async function jobExists(cfg, jobPath) {
21
+ const res = await fetch(`${jobUrl(cfg, jobPath)}/api/json?tree=name`, {
22
+ headers: authHeaders(cfg),
23
+ });
24
+ if (res.ok)
25
+ return true;
26
+ if (res.status === 404)
27
+ return false;
28
+ throw await jenkinsError(`Jenkins 잡 존재 확인 실패: ${jobPath}`, res);
29
+ }
30
+ /** 잡의 config.xml 원문. 백업하거나 수정 전 현재 상태를 확인할 때 쓴다. */
31
+ export async function getJobConfig(cfg, jobPath) {
32
+ const res = await fetch(`${jobUrl(cfg, jobPath)}/config.xml`, {
33
+ headers: authHeaders(cfg),
34
+ });
35
+ if (!res.ok)
36
+ throw await jenkinsError(`Jenkins 잡 config 조회 실패: ${jobPath}`, res);
37
+ return res.text();
38
+ }
39
+ /**
40
+ * 새 잡 생성. 같은 이름이 이미 있으면 Jenkins 가 400 을 준다.
41
+ * 폴더 안에 만들려면 jobPath 에 "folder/name" 을 넘긴다 (폴더는 미리 존재해야 함).
42
+ */
43
+ export async function createJob(cfg, jobPath, configXml) {
44
+ const crumb = await getCrumb(cfg);
45
+ const res = await fetch(createItemUrl(cfg, jobPath), {
46
+ method: 'POST',
47
+ headers: {
48
+ ...authHeaders(cfg),
49
+ 'Content-Type': 'application/xml',
50
+ ...crumb,
51
+ },
52
+ body: configXml,
53
+ });
54
+ if (!res.ok)
55
+ throw await jenkinsError(`Jenkins 잡 생성 실패: ${jobPath}`, res);
56
+ }
57
+ /** 기존 잡의 config.xml 교체. 잡이 없으면 404. */
58
+ export async function updateJob(cfg, jobPath, configXml) {
59
+ const crumb = await getCrumb(cfg);
60
+ const res = await fetch(`${jobUrl(cfg, jobPath)}/config.xml`, {
61
+ method: 'POST',
62
+ headers: {
63
+ ...authHeaders(cfg),
64
+ 'Content-Type': 'application/xml',
65
+ ...crumb,
66
+ },
67
+ body: configXml,
68
+ });
69
+ if (!res.ok)
70
+ throw await jenkinsError(`Jenkins 잡 수정 실패: ${jobPath}`, res);
71
+ }
72
+ /** 있으면 수정, 없으면 생성. Jenkinsfile/job-config.xml 을 레포에서 밀어넣을 때 쓴다. */
73
+ export async function upsertJob(cfg, jobPath, configXml) {
74
+ if (await jobExists(cfg, jobPath)) {
75
+ await updateJob(cfg, jobPath, configXml);
76
+ return 'updated';
77
+ }
78
+ await createJob(cfg, jobPath, configXml);
79
+ return 'created';
80
+ }
@@ -67,6 +67,15 @@ export function withCause(err, cause) {
67
67
  err.cause = cause;
68
68
  return err;
69
69
  }
70
+ /**
71
+ * 일부 호출자(예: firebase_create_project)는 "이미 부분적으로 완료된 작업이 있다"는
72
+ * 사실을 에러 객체에 `partialFailureNote`로 남겨둔다 — 어떤 분기로 친절화되든
73
+ * (또는 인식 못 해 원본 그대로 통과하든) 그 경고가 사라지지 않도록 항상 앞에 붙인다.
74
+ */
75
+ function withPartialNote(e, message) {
76
+ const note = e?.partialFailureNote;
77
+ return note ? `${note}\n\n${message}` : message;
78
+ }
70
79
  /**
71
80
  * Firebase / GCP 에러를 친절 메시지로 변환. 신규 사용자가 가장 자주 만나는
72
81
  * 'API 미활성화 / 프로젝트 없음 / billing / 권한' 케이스에 다음 행동을 붙인다.
@@ -76,22 +85,28 @@ export function friendlyGoogleError(e) {
76
85
  const status = extractHttpStatus(e);
77
86
  const reauth = authReauthMessage(text);
78
87
  if (reauth)
79
- return withCause(new Error(reauth), e);
88
+ return withCause(new Error(withPartialNote(e, reauth)), e);
80
89
  if (/SERVICE_DISABLED|has not been used in project|is disabled|accessNotConfigured/i.test(text)) {
81
90
  const url = extractActivationUrl(text);
82
- return withCause(new Error([
91
+ return withCause(new Error(withPartialNote(e, [
83
92
  '❌ 필요한 Google API가 비활성화돼 있어요.',
84
93
  url ? `→ 활성화: ${url}` : '→ Google Cloud Console에서 해당 API를 활성화한 뒤 다시 시도하세요.',
85
- ].join('\n')), e);
94
+ ].join('\n'))), e);
86
95
  }
87
96
  if (/BILLING_DISABLED|billing.*(disabled|not enabled|required)/i.test(text)) {
88
- return withCause(new Error('❌ 결제(billing)가 비활성화된 프로젝트예요.\n→ Google Cloud Console → 결제 에서 결제 계정을 연결하세요.'), e);
97
+ return withCause(new Error(withPartialNote(e, '❌ 결제(billing)가 비활성화된 프로젝트예요.\n→ Google Cloud Console → 결제 에서 결제 계정을 연결하세요.')), e);
98
+ }
99
+ if (/ALREADY_EXISTS|already exists|already in use/i.test(text)) {
100
+ return withCause(new Error(withPartialNote(e, '❌ 이미 사용 중인 ID예요 (GCP 프로젝트 ID는 전역에서 유일해야 함).\n→ 다른 projectId로 다시 시도하거나, firebase_get_project 로 소유권을 확인하세요.')), e);
89
101
  }
90
102
  if (status === 404 || /NOT_FOUND|not found/i.test(text)) {
91
- return withCause(new Error('❌ 프로젝트/리소스를 찾을 수 없어요.\n→ firebase_list_projects 로 유효한 projectId를 확인하세요.'), e);
103
+ return withCause(new Error(withPartialNote(e, '❌ 프로젝트/리소스를 찾을 수 없어요.\n→ firebase_list_projects 로 유효한 projectId를 확인하세요.')), e);
92
104
  }
93
105
  if (status === 403 || /PERMISSION_DENIED|forbidden|permission/i.test(text)) {
94
- return withCause(new Error('❌ 권한 부족 — 이 Google 계정이 해당 프로젝트에 접근 권한이 있는지 확인하세요.'), e);
106
+ return withCause(new Error(withPartialNote(e, '❌ 권한 부족 — 이 Google 계정이 해당 프로젝트에 접근 권한이 있는지 확인하세요.')), e);
95
107
  }
108
+ const note = e?.partialFailureNote;
109
+ if (note)
110
+ return withCause(new Error(`${note}\n\n${text}`), e);
96
111
  return e instanceof Error ? e : new Error(text);
97
112
  }
@@ -37,6 +37,43 @@ export function registerFirebaseTools(server) {
37
37
  const project = await firebase.getProject(auth, projectId);
38
38
  return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] };
39
39
  });
40
+ server.tool('firebase_create_project', [
41
+ '새 GCP 프로젝트를 만들고 Firebase를 추가한다 (앱 하나당 전용 Firebase 프로젝트 컨벤션 — 기존 프로젝트에',
42
+ '앱만 추가하려면 firebase_create_android_app/firebase_create_ios_app 사용).',
43
+ '두 개의 long-running operation(프로젝트 생성 → Firebase 추가)을 순서대로 완료까지 폴링하므로 최악의 경우 2분 정도 걸릴 수 있다.',
44
+ 'projectId는 6-30자, 소문자로 시작, 소문자/숫자/하이픈만 가능(끝에 하이픈 금지).',
45
+ ].join(' '), {
46
+ projectId: z
47
+ .string()
48
+ .regex(/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/, '소문자로 시작, 소문자/숫자/하이픈 6-30자 (끝에 하이픈 불가)')
49
+ .describe('새로 만들 GCP/Firebase 프로젝트 ID (예: pryzm-penguinrun)'),
50
+ displayName: z.string().describe('프로젝트 표시 이름'),
51
+ parent: z
52
+ .string()
53
+ .optional()
54
+ .describe("조직/폴더 소속이 필요한 계정일 때만: 'organizations/<id>' 또는 'folders/<id>'. 생략 시 계정 기본 정책대로 생성"),
55
+ }, async ({ projectId, displayName, parent }) => {
56
+ const auth = await requireAuth();
57
+ const project = await firebase.createProject(auth, projectId, displayName, { parent });
58
+ return {
59
+ content: [
60
+ {
61
+ type: 'text',
62
+ text: [
63
+ `✓ Firebase 프로젝트 생성 완료: \`${project.projectId}\``,
64
+ '',
65
+ `**displayName**: ${project.displayName}`,
66
+ `**projectNumber**: ${project.projectNumber}`,
67
+ '',
68
+ '다음 단계:',
69
+ `1. firebase_create_android_app("${project.projectId}", packageName, displayName)`,
70
+ `2. firebase_create_ios_app("${project.projectId}", bundleId, displayName)`,
71
+ '3. firebase_get_android_config / firebase_get_ios_config 로 설정 파일 다운로드',
72
+ ].join('\n'),
73
+ },
74
+ ],
75
+ };
76
+ });
40
77
  server.tool('firebase_list_android_apps', 'Firebase 프로젝트의 Android 앱 목록', { projectId: z.string().describe('Firebase 프로젝트 ID') }, async ({ projectId }) => {
41
78
  const auth = await requireAuth();
42
79
  const apps = await firebase.listAndroidApps(auth, projectId);
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { loadJenkinsConfig, requireJenkinsConfig, saveJenkinsConfig } from '../jenkins/config.js';
3
3
  import * as creds from '../jenkins/credentials.js';
4
+ import * as jobs from '../jenkins/jobs.js';
4
5
  export function registerJenkinsTools(server) {
5
6
  // ── 0. 상태 확인 (항상 첫 번째로 호출) ─────────────────────────────────────
6
7
  server.tool('jenkins_status', [
@@ -42,6 +43,7 @@ export function registerJenkinsTools(server) {
42
43
  '',
43
44
  'jenkins_list_credentials 로 등록된 credential 목록을 확인하거나,',
44
45
  'jenkins_create_credential / jenkins_upload_keystore 로 credential을 추가하세요.',
46
+ '잡은 jenkins_list_jobs / jenkins_get_job_config / jenkins_create_job / jenkins_update_job 로 다룹니다.',
45
47
  ].join('\n'),
46
48
  }],
47
49
  };
@@ -147,4 +149,77 @@ export function registerJenkinsTools(server) {
147
149
  }],
148
150
  };
149
151
  });
152
+ // ── 6. 잡 목록 ─────────────────────────────────────────────────────────────
153
+ server.tool('jenkins_list_jobs', [
154
+ 'Jenkins 잡 목록을 조회합니다 (이름 / URL / 상태색).',
155
+ 'folder를 주면 그 폴더 안만 조회합니다. 재귀하지 않고 한 단계만 봅니다.',
156
+ '설정이 없으면 jenkins_status를 먼저 호출하세요.',
157
+ ].join(' '), {
158
+ folder: z.string().optional().describe('폴더 경로 (예: vir-game). 생략하면 루트'),
159
+ }, async ({ folder }) => {
160
+ const cfg = requireJenkinsConfig();
161
+ const list = await jobs.listJobs(cfg, folder);
162
+ if (list.length === 0) {
163
+ return { content: [{ type: 'text', text: '잡 없음.' }] };
164
+ }
165
+ const lines = list.map((j) => `• ${j.name}${j.color ? ` [${j.color}]` : ' [folder]'}`);
166
+ return {
167
+ content: [{
168
+ type: 'text',
169
+ text: `Jenkins jobs (${list.length}개):\n\n${lines.join('\n')}`,
170
+ }],
171
+ };
172
+ });
173
+ // ── 7. 잡 config.xml 조회 ──────────────────────────────────────────────────
174
+ server.tool('jenkins_get_job_config', [
175
+ '잡의 config.xml 원문을 가져옵니다.',
176
+ '잡을 수정하기 전에 현재 설정을 확인하거나 백업할 때 사용하세요.',
177
+ '폴더 안의 잡은 "folder/job" 형태로 경로를 넘깁니다.',
178
+ ].join(' '), {
179
+ job: z.string().describe('잡 경로 (예: penguinrun, vir-game/client)'),
180
+ }, async ({ job }) => {
181
+ const cfg = requireJenkinsConfig();
182
+ const xml = await jobs.getJobConfig(cfg, job);
183
+ return { content: [{ type: 'text', text: xml }] };
184
+ });
185
+ // ── 8. 잡 생성 ─────────────────────────────────────────────────────────────
186
+ server.tool('jenkins_create_job', [
187
+ 'config.xml 로 새 Jenkins 잡을 생성합니다 (createItem).',
188
+ '같은 이름의 잡이 이미 있으면 실패합니다. 덮어쓰려면 overwrite=true 를 주거나 jenkins_update_job 을 쓰세요.',
189
+ '폴더 안에 만들려면 job 에 "folder/name" 을 넘깁니다 (폴더는 미리 존재해야 합니다).',
190
+ 'config_xml 은 Pipeline job 이면 flow-definition 루트 엘리먼트를 갖는 XML 전문입니다.',
191
+ ].join(' '), {
192
+ job: z.string().describe('잡 경로 (예: penguinrun, vir-game/client)'),
193
+ config_xml: z.string().describe('잡 정의 config.xml 전문'),
194
+ overwrite: z.boolean().default(false).describe('이미 존재하면 덮어쓸지 여부 (기본 false)'),
195
+ }, async ({ job, config_xml, overwrite }) => {
196
+ const cfg = requireJenkinsConfig();
197
+ if (overwrite) {
198
+ const result = await jobs.upsertJob(cfg, job, config_xml);
199
+ return { content: [{ type: 'text', text: `✅ Jenkins 잡 ${result}: \`${job}\`` }] };
200
+ }
201
+ if (await jobs.jobExists(cfg, job)) {
202
+ return {
203
+ content: [{
204
+ type: 'text',
205
+ text: `⚠️ 잡 \`${job}\` 이(가) 이미 존재합니다. jenkins_update_job 을 쓰거나 overwrite=true 로 다시 호출하세요.`,
206
+ }],
207
+ };
208
+ }
209
+ await jobs.createJob(cfg, job, config_xml);
210
+ return { content: [{ type: 'text', text: `✅ Jenkins 잡 created: \`${job}\`` }] };
211
+ });
212
+ // ── 9. 잡 수정 ─────────────────────────────────────────────────────────────
213
+ server.tool('jenkins_update_job', [
214
+ '기존 Jenkins 잡의 config.xml 을 통째로 교체합니다.',
215
+ '잡이 없으면 실패합니다 (생성은 jenkins_create_job).',
216
+ '기존 설정이 사라지므로 jenkins_get_job_config 로 먼저 백업하는 것을 권장합니다.',
217
+ ].join(' '), {
218
+ job: z.string().describe('잡 경로 (예: penguinrun, vir-game/client)'),
219
+ config_xml: z.string().describe('교체할 config.xml 전문'),
220
+ }, async ({ job, config_xml }) => {
221
+ const cfg = requireJenkinsConfig();
222
+ await jobs.updateJob(cfg, job, config_xml);
223
+ return { content: [{ type: 'text', text: `✅ Jenkins 잡 updated: \`${job}\`` }] };
224
+ });
150
225
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {