refactor(core): MCP 역할별 접근 제어 + asyncio.Lock 추가

- ROLE_MCP_ACCESS: agent만 MCP 도구 접근, 나머지 역할은 제거
- _settings_lock: settings.json 쓰기~프로세스 시작 직렬화
- agent.md: 쉘 명령 금지, MCP 도구 강제 사용 지시 추가
- config.py: WORKSPACE_BASE_DIR 경로 수정 (Variet-Worker)
- run_bot.bat: conda 환경 variet-agent로 변경
- workspaces.json: orphan 정리 + 경로 수정
- known-issues: MCP 접근제어, yolo 자율성, Nyaa 카테고리 이슈 추가
- devlog: 002 entry 및 index 업데이트
This commit is contained in:
2026-03-12 22:03:00 +09:00
parent 092bd588be
commit 63818999d9
8 changed files with 112 additions and 80 deletions

View File

@@ -63,3 +63,21 @@
- **원인**: Gemini CLI는 `cwd` 기준으로 `.gemini/settings.json`을 탐색. cwd가 다른 워크스페이스면 MCP 설정 못 찾음 - **원인**: Gemini CLI는 `cwd` 기준으로 `.gemini/settings.json`을 탐색. cwd가 다른 워크스페이스면 MCP 설정 못 찾음
- **해결**: `~/.gemini/settings.json` (홈 레벨)에 mcpServers 등록. `_set_thinking_budget`에서 자동 관리 - **해결**: `~/.gemini/settings.json` (홈 레벨)에 mcpServers 등록. `_set_thinking_budget`에서 자동 관리
- **주의**: MCP 서버 설정은 반드시 홈 레벨 settings.json에 등록. 프로젝트 레벨은 불충분 - **주의**: MCP 서버 설정은 반드시 홈 레벨 settings.json에 등록. 프로젝트 레벨은 불충분
### [2026-03-12] MCP 역할별 접근 제어 — 모든 역할이 MCP 도구 접근
- **증상**: coder, reviewer 등 텍스트 전용 역할도 anime/infra MCP 도구에 접근 가능
- **원인**: `_set_thinking_budget()`이 역할 무관하게 모든 MCP 서버를 settings.json에 등록
- **해결**: `ROLE_MCP_ACCESS` dict 추가, agent만 MCP 등록, 나머지는 제거. `asyncio.Lock` 추가로 settings.json 레이스 방지
- **주의**: settings.json은 글로벌 파일이므로, 역할 전환 시 반드시 이전 설정을 정리해야 함
### [2026-03-12] Gemini CLI yolo — 에이전트 자율성 위험
- **증상**: 애니 다운로드 요청 시 에이전트가 음악/만화를 다운로드하고 엉뚱한 폴더 생성
- **원인**: `--approval-mode yolo`는 MCP 도구 + 쉘 + 파일 조작 모두 무승인 허용. 프롬프트 제한은 강제력 없음
- **해결 (검토중)**: MCP 대신 Python 도구를 소스코드로 직접 제공하여 Gemini CLI가 읽고 사용하는 방식 검토
- **주의**: 프롬프트는 "부탁"이지 "강제"가 아님. 안전장치는 코드(도구) 레벨에 구현해야 함
### [2026-03-12] Nyaa 검색 — anime 카테고리 미지정
- **증상**: 애니 검색 시 음악, 만화, 라이트노벨 등 무관한 토렌트가 다운로드됨
- **원인**: `NyaaClient.search()` 기본 category가 `0_0`(전체). Music, Manga 등 포함
- **해결 (예정)**: 기본 category를 `1_2`(Anime English) 또는 `1_0`(Anime 전체)로 변경
- **주의**: 외부 검색 API 사용 시 반드시 카테고리/필터를 명시적으로 지정

View File

@@ -47,7 +47,7 @@ VIKUNJA_PROJECT_ID: int = int(os.getenv("VIKUNJA_PROJECT_ID", "7"))
# === Workspace === # === Workspace ===
WORKSPACE_BASE_DIR: str = os.getenv( WORKSPACE_BASE_DIR: str = os.getenv(
"WORKSPACE_BASE_DIR", r"c:\Users\Certes\Desktop\VW_Proj" "WORKSPACE_BASE_DIR", r"c:\Users\Variet-Worker\Desktop\VW_Proj"
) )
# === qBittorrent === # === qBittorrent ===

View File

@@ -29,9 +29,23 @@ ROLE_THINKING: dict[str, int] = {
} }
DEFAULT_THINKING = 4096 DEFAULT_THINKING = 4096
# 역할별 MCP 서버 접근 허용 목록
# agent만 외부 도구 사용 가능, 나머지 역할은 텍스트 전용
ROLE_MCP_ACCESS: dict[str, list[str]] = {
"agent": ["anime", "infra"],
"coder": [],
"planner": [],
"reviewer": [],
"summarizer": [],
"unified": [],
}
# 동시 호출 제한 (Gemini AI Ultra 120RPM 고려) # 동시 호출 제한 (Gemini AI Ultra 120RPM 고려)
_semaphore = asyncio.Semaphore(4) _semaphore = asyncio.Semaphore(4)
# settings.json 쓰기 경합 방지 (settings 쓰기 ~ 프로세스 시작 구간 직렬화)
_settings_lock = asyncio.Lock()
# Windows에서 PS ExecutionPolicy 우회 # Windows에서 PS ExecutionPolicy 우회
_IS_WIN = sys.platform == "win32" _IS_WIN = sys.platform == "win32"
@@ -76,8 +90,13 @@ class GeminiCaller:
} }
def _set_thinking_budget(self, role: str): def _set_thinking_budget(self, role: str):
"""역할별 thinkingBudget + MCP 서버 설정을 settings.json에 반영.""" """역할별 thinkingBudget + MCP 서버 설정을 settings.json에 반영.
MCP 서버는 ROLE_MCP_ACCESS에 따라 역할별로 필터링됩니다.
agent 역할만 MCP 도구에 접근 가능하고, 나머지 역할은 제거됩니다.
"""
budget = ROLE_THINKING.get(role, DEFAULT_THINKING) budget = ROLE_THINKING.get(role, DEFAULT_THINKING)
allowed_mcp = ROLE_MCP_ACCESS.get(role, [])
try: try:
if _SETTINGS_PATH.exists(): if _SETTINGS_PATH.exists():
settings = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8")) settings = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
@@ -91,16 +110,22 @@ class GeminiCaller:
thinking = default.setdefault("thinkingConfig", {}) thinking = default.setdefault("thinkingConfig", {})
thinking["thinkingBudget"] = budget thinking["thinkingBudget"] = budget
# MCP 서버 (홈 레벨에 등록 — cwd와 무관하게 항상 사용 가능) # MCP 서버 — 역할별 접근 제어
mcp_servers = settings.setdefault("mcpServers", {}) mcp_servers = settings.setdefault("mcpServers", {})
for name, config in self._MCP_SERVERS.items(): for name, cfg in self._MCP_SERVERS.items():
mcp_servers[name] = config if name in allowed_mcp:
mcp_servers[name] = cfg
else:
mcp_servers.pop(name, None)
_SETTINGS_PATH.write_text( _SETTINGS_PATH.write_text(
json.dumps(settings, indent=2, ensure_ascii=False), json.dumps(settings, indent=2, ensure_ascii=False),
encoding="utf-8", encoding="utf-8",
) )
logger.debug(f"thinkingBudget={budget} for role={role}") logger.debug(
f"settings.json 업데이트: role={role}, budget={budget}, "
f"mcp={allowed_mcp or 'none'}"
)
except Exception as e: except Exception as e:
logger.warning(f"settings.json 업데이트 실패 (role={role}): {e}") logger.warning(f"settings.json 업데이트 실패 (role={role}): {e}")
@@ -132,7 +157,6 @@ class GeminiCaller:
async def _call_text(self, role: str, context: str, timeout: int) -> str: async def _call_text(self, role: str, context: str, timeout: int) -> str:
"""텍스트 전용 호출.""" """텍스트 전용 호출."""
self._set_thinking_budget(role)
prompt_file = ROLE_PROMPTS_DIR / f"{role}.md" prompt_file = ROLE_PROMPTS_DIR / f"{role}.md"
if prompt_file.exists(): if prompt_file.exists():
system_prompt = prompt_file.read_text(encoding="utf-8") system_prompt = prompt_file.read_text(encoding="utf-8")
@@ -147,12 +171,15 @@ class GeminiCaller:
) )
try: try:
proc = await asyncio.create_subprocess_exec( # Lock: settings.json 쓰기 ~ 프로세스 시작 직렬화
*self._build_cmd(), async with _settings_lock:
stdin=asyncio.subprocess.PIPE, self._set_thinking_budget(role)
stdout=asyncio.subprocess.PIPE, proc = await asyncio.create_subprocess_exec(
stderr=asyncio.subprocess.PIPE, *self._build_cmd(),
) stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for( stdout, stderr = await asyncio.wait_for(
proc.communicate(input=full_input.encode("utf-8")), proc.communicate(input=full_input.encode("utf-8")),
timeout=timeout, timeout=timeout,
@@ -207,7 +234,6 @@ class GeminiCaller:
self, role: str, context: str, cwd: str, timeout: int, self, role: str, context: str, cwd: str, timeout: int,
) -> str: ) -> str:
"""에이전트 모드 구현.""" """에이전트 모드 구현."""
self._set_thinking_budget(role)
prompt_file = ROLE_PROMPTS_DIR / f"{role}.md" prompt_file = ROLE_PROMPTS_DIR / f"{role}.md"
if prompt_file.exists(): if prompt_file.exists():
system_prompt = prompt_file.read_text(encoding="utf-8") system_prompt = prompt_file.read_text(encoding="utf-8")
@@ -241,13 +267,16 @@ class GeminiCaller:
) )
try: try:
proc = await asyncio.create_subprocess_exec( # Lock: settings.json 쓰기 ~ 프로세스 시작 직렬화
*self._build_cmd(), async with _settings_lock:
stdin=asyncio.subprocess.PIPE, self._set_thinking_budget(role)
stdout=asyncio.subprocess.PIPE, proc = await asyncio.create_subprocess_exec(
stderr=asyncio.subprocess.PIPE, *self._build_cmd(),
cwd=cwd, # ★ 핵심: 프로젝트 디렉토리에서 실행 stdin=asyncio.subprocess.PIPE,
) stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd, # ★ 핵심: 프로젝트 디렉토리에서 실행
)
stdout, stderr = await asyncio.wait_for( stdout, stderr = await asyncio.wait_for(
proc.communicate(input=full_input.encode("utf-8")), proc.communicate(input=full_input.encode("utf-8")),
timeout=timeout, timeout=timeout,

View File

@@ -3,3 +3,6 @@
| # | 시간 | 작업 | 커밋 | 상태 | | # | 시간 | 작업 | 커밋 | 상태 |
|---|------|------|------|------| |---|------|------|------|------|
| 001 | 16:45 | MCP 기반 에이전트 아키텍처 재설계 — unified.md 분류기 → Gemini CLI + MCP 자율 에이전트 전환 | `246d2a2` | ✅ | | 001 | 16:45 | MCP 기반 에이전트 아키텍처 재설계 — unified.md 분류기 → Gemini CLI + MCP 자율 에이전트 전환 | `246d2a2` | ✅ |
| 002 | 18:30 | MCP 역할별 접근 제어 + asyncio.Lock 추가, run_bot.bat 환경 수정, 아키텍처 정밀 검토 | - | 🔧 |
| 003 | 19:30 | 봇 실행 테스트 — fastapi 설치, workspace 경로 수정, 에이전트 자율성 문제 발견 | - | 🔧 |
| 004 | 20:00 | 아키텍처 재분석 — MCP vs 구조화 파이프라인 vs Python 도구 직접 제공 방식 비교 검토 | - | 🔧 |

View File

@@ -0,0 +1,24 @@
# MCP 역할별 접근 제어 + 아키텍처 재분석
- **시간**: 2026-03-12 18:30~21:59
- **Commit**: (이번 커밋에 포함)
- **Vikunja**: 신규 태스크 생성 예정
## 결정 사항
### MCP 역할별 접근 제어 (구현 완료)
- `ROLE_MCP_ACCESS` dict로 agent만 MCP 도구 접근 허용
- `asyncio.Lock`으로 settings.json 쓰기~프로세스 시작 구간 직렬화
- 나머지 역할(coder, reviewer 등)은 MCP 제거됨
### 아키텍처 방향성 (검토중)
- **MCP 방식의 문제**: `--approval-mode yolo`에서 에이전트가 쉘/파일 조작 무제한 → 음악/만화 다운 등 예측 불가 동작
- **검토 중인 대안**: MCP 대신 Python 도구를 소스코드로 직접 제공 → Gemini CLI가 읽고 이해하여 사용
- **핵심 원칙**: "프롬프트는 부탁, 코드는 법률" → 안전장치는 도구 코드 레벨에 구현
## 미완료
- [ ] 아키텍처 최종 결정 (MCP → Python 도구 직접 제공 방식 전환)
- [ ] Nyaa 검색 카테고리 `1_2`로 변경
- [ ] NAS 기존 폴더 매칭 로직 추가
- [ ] discord_bot.py dead code ~572줄 정리
- [ ] config.py .env 따옴표 처리

View File

@@ -10,26 +10,35 @@
- 여러 도구를 **순서대로** 사용해야 할 때도 있습니다. - 여러 도구를 **순서대로** 사용해야 할 때도 있습니다.
- 도구 호출 결과가 불충분하면 **다른 도구를 시도**하거나 **다른 파라미터**로 재호출하세요. - 도구 호출 결과가 불충분하면 **다른 도구를 시도**하거나 **다른 파라미터**로 재호출하세요.
## ⛔ 절대 금지
- **쉘 명령어로 직접 다운로드하지 마세요** (curl, wget, pip install 등)
- **파일을 직접 생성/수정하지 마세요** — MCP 도구만 사용하세요
- 사용자가 요청하지 않은 작업을 임의로 수행하지 마세요
## 사용 가능한 도구 영역 ## 사용 가능한 도구 영역
### 🎬 anime 서버 ### 🎬 anime 서버 — 애니메이션 관련은 반드시 이 도구만 사용
- `anime_search` — 애니 검색 (제목, 자막, 토렌트) - `anime_search` — 애니 검색 (제목, 자막, 토렌트)
- `anime_download` — 애니 다운로드 (자막+영상) - `anime_download` — 애니 다운로드 (자막+영상). 한 번에 **하나의 작품**만 다운로드.
- `anime_schedule` — 편성표 조회 - `anime_schedule` — 편성표 조회
- `anime_download_status` — qBittorrent 상태 - `anime_download_status` — qBittorrent 상태
- `anime_nas_list` — NAS 다운로드 목록 - `anime_nas_list` — NAS 다운로드 목록
### 🔧 infra 서버 ### 🔧 infra 서버 — Git/태스크 관련은 반드시 이 도구만 사용
- `gitea_commits`, `gitea_prs`, `gitea_issues`, `gitea_branches` — Git 관리 - `gitea_commits`, `gitea_prs`, `gitea_issues`, `gitea_branches` — Git 관리
- `vikunja_tasks`, `vikunja_create_task`, `vikunja_complete_task` — 태스크 관리 - `vikunja_tasks`, `vikunja_create_task`, `vikunja_complete_task` — 태스크 관리
### 💻 내장 도구 ## 복수 작품 처리 방법
- 프로젝트 파일 읽기/쓰기, 쉘 명령 실행 등 Gemini CLI 내장 도구 사용 가능
사용자가 "여러 작품 다운로드" 등 복수 작업을 요청하면:
1. 먼저 `anime_nas_list`로 대상 목록을 확인하세요
2. 각 작품마다 `anime_download`를 **개별 호출**하세요
3. 진행 상황과 결과를 정리하여 보고하세요
## 응답 규칙 ## 응답 규칙
- **한국어**로 응답하세요. - **한국어**로 응답하세요.
- 도구 실행 결과를 사용자에게 **알기 쉽게 정리**하세요. - 도구 실행 결과를 사용자에게 **알기 쉽게 정리**하세요.
- 파일 변경 시 **변경 요약**을 제공하세요.
- 에러 발생 시 **원인과 대안**을 안내하세요. - 에러 발생 시 **원인과 대안**을 안내하세요.
- 불필요하게 길지 않게, **핵심만** 전달하세요. - 불필요하게 길지 않게, **핵심만** 전달하세요.

View File

@@ -7,7 +7,7 @@ echo Variet Agent 시작
echo ========================================== echo ==========================================
echo. echo.
C:\ProgramData\miniforge3\envs\agent_chat\python.exe main.py C:\ProgramData\miniforge3\envs\variet-agent\python.exe main.py
echo. echo.
echo 봇이 종료되었습니다. 아무 키나 누르면 창을 닫습니다. echo 봇이 종료되었습니다. 아무 키나 누르면 창을 닫습니다.

View File

@@ -1,58 +1,7 @@
{ {
"5608566207": {
"name": "test_1_orphan_20260307",
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1_orphan_20260307",
"channel_id": 0,
"git": {
"url": "",
"token": "",
"repo": "",
"branch": "main"
},
"vikunja": {
"url": "",
"token": "",
"project_id": 0
},
"docs_path": "docs/wiki"
},
"8350378037": {
"name": "test_2_orphan_20260307",
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_2_orphan_20260307",
"channel_id": 0,
"git": {
"url": "",
"token": "",
"repo": "",
"branch": "main"
},
"vikunja": {
"url": "",
"token": "",
"project_id": 0
},
"docs_path": "docs/wiki"
},
"1479610776502403186": {
"name": "test_1",
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1",
"channel_id": 1479610776502403186,
"git": {
"url": "",
"token": "",
"repo": "",
"branch": "main"
},
"vikunja": {
"url": "",
"token": "",
"project_id": 0
},
"docs_path": "docs/wiki"
},
"1480113683849023661": { "1480113683849023661": {
"name": "variet-agent", "name": "variet-agent",
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\variet-agent", "path": "c:\\Users\\Variet-Worker\\Desktop\\VW_Proj\\variet-agent",
"channel_id": 1480113683849023661, "channel_id": 1480113683849023661,
"git": { "git": {
"url": "", "url": "",