이미지 압축
nx-mcp-server 원격 압축 서비스를 통해 이미지를 스마트하게 압축합니다.
설정
프로젝트 루트 디렉토리에 .mcp.json 생성:
{
"mcpServers": {
"nx-mcp-compress": {
"type": "url",
"url": "https://mcp.api-inference.modelscope.net/4378d43d3e7d4c/mcp",
"env": {
"NX_API_KEY": "your API Key"
}
}
}
}
찾는 순서: 프로젝트 루트 → 사용자 홈 디렉토리. url → MCP_URL, env.NX_API_KEY → API_KEY로 추출합니다.
스킬이 MCP 엔드포인트에 직접 연결되며, Claude Code를 재시작할 필요가 없습니다.
API Key가 없으신가요? WeChat
zhjian_2026으로 문의하여 받으세요.
압축 과정
1단계: 설정 확인
cat .mcp.json 2>/dev/null || cat ~/.mcp.json 2>/dev/null
- 찾음 →
url과NX_API_KEY기록, 2단계로 진행 - 못 찾음 → 사용자에게 API Key 보유 여부 질문:
- Key 있음: 사용자를 위해
~/.mcp.json생성 (전역 및 프로젝트 설치 모두 호환) - Key 없음: WeChat
zhjian_2026으로 문의하도록 안내
- Key 있음: 사용자를 위해
⚠️ 설정이 없으면 이후 단계를 진행하지 마세요.
2단계: 압축 실행 (한 번의 Bash 호출, 순수 메모리, 파일 생성 없음)
PIC_DIR, MCP_URL, API_KEY, QUALITY를 교체한 후, heredoc을 통해 직접 stdin으로 node에 전달:
NODE_PATH=$(npm root -g) node << 'COMPRESSEOF'
const fs=require('fs'),path=require('path');
let PIC_DIR='<target image directory absolute path>';
const SINGLE_FILE='<single image path, leave empty to compress entire directory>';
const MCP_URL='<url from .mcp.json>';
const API_KEY='<NX_API_KEY from .mcp.json>';
const QUALITY=90;
(async()=>{
const exts=['.png','.jpg','.jpeg','.webp','.bmp','.tga'];
let imgs;
if(SINGLE_FILE){imgs=[path.basename(SINGLE_FILE)];PIC_DIR=path.dirname(SINGLE_FILE)}
else{imgs=fs.readdirSync(PIC_DIR).filter(f=>exts.includes(path.extname(f).toLowerCase())).sort()}
const origTotal=imgs.reduce((s,f)=>s+fs.statSync(path.join(PIC_DIR,f)).size,0);
console.log(`총 ${imgs.length}장, ${(origTotal/1024).toFixed(0)}KB`);
const maxSize=5*1024*1024;
const records=[];let skip=0;
for(let i=0;i<imgs.length;i++){const f=imgs[i],fp=path.join(PIC_DIR,f),osz=fs.statSync(fp).size;
if(osz>maxSize){records.push({name:f,origKb:osz,compKb:0,compUrl:null,ratio:null,error:'5MB 초과'});skip++;console.log(` [${i+1}/${imgs.length}] ${f} ${(osz/1024).toFixed(0)}KB ⚠️ 5MB 초과로 건너뜀`);continue}
const buf=fs.readFileSync(fp);const ext=path.extname(f).toLowerCase().slice(1);
const mime=ext==='png'?'image/png':ext==='webp'?'image/webp':'image/jpeg';
records.push({name:f,origKb:osz,compKb:0,compUrl:null,ratio:null,dataUrl:'data:'+mime+';base64,'+buf.toString('base64')});}
console.log(`압축 가능: ${records.length-skip}장, 건너뜀: ${skip}장`);
console.time('압축');
const H={'Content-Type':'application/json','Accept':'application/json, text/event-stream'};
const r1=await fetch(MCP_URL,{method:'POST',headers:H,body:JSON.stringify({jsonrpc:'2.0',id:'1',method:'initialize',params:{protocolVersion:'2025-06-18',capabilities:{},clientInfo:{name:'cc',version:'1'}}})});
H['Mcp-Session-Id']=r1.headers.get('Mcp-Session-Id');console.log('MCP: init');
await fetch(MCP_URL,{method:'POST',headers:H,body:JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})});console.log('MCP: notified');
// 동시성 풀: 항상 LIMIT개의 요청이 실행 중, 완료 시 하나 추가
const LIMIT=5;
let totalSaved=0,pass=0,fail=0,done=0;
const pending=records.filter(r=>!r.error);
async function compressOne(r,idx){
try{
const r3=await fetch(MCP_URL,{method:'POST',headers:H,body:JSON.stringify({jsonrpc:'2.0',id:'3',method:'tools/call',params:{name:'nx_compress',arguments:{files:[r.dataUrl],quality:QUALITY,apiKey:API_KEY}}})});
const raw=await r3.json();
if(!raw.result){r.error='MCP: '+(raw.error?.message||'unknown');fail++}
else{
const inner=JSON.parse(raw.result.content[0].text);
if(inner.error){r.error=inner.code+': '+inner.error;fail++}
else{const it=inner.items[0];
if(it.error){r.error=it.error;fail++}
else{r.compKb=it.compressedSize/1024;r.ratio=it.ratio;r.compUrl=it.compressedUrl;totalSaved+=it.originalSize-it.compressedSize;pass++;console.log(` [${idx+1}/${imgs.length}] ${r.name} ${(r.origKb/1024).toFixed(0)}KB→${(r.compKb).toFixed(0)}KB (${r.ratio})`)}
}
}
}catch(e){r.error=e.message;fail++}
done++;
}
// 슬라이딩 윈도우: 항상 LIMIT개의 동시 요청 유지
let i=0;
const running=new Set();
while(i<pending.length||running.size>0){
while(running.size<LIMIT&&i<pending.length){
const p=compressOne(pending[i],i).then(()=>running.delete(p));
running.add(p);i++;
}
if(running.size>0) await Promise.race(running);
}console.timeEnd('압축');
console.log('\n'+'='.repeat(100));
console.log(`${'파일'.padEnd(40)} ${'원본'.padStart(8)} ${'압축 후'.padStart(8)} ${'압축률'.padStart(8)} ${'결과'.padStart(6)}`);
console.log('-'.repeat(100));
for(const r of records){const oszS=(r.origKb/1024).toFixed(1)+'KB';
if(r.compUrl){const cszS=(r.compKb).toFixed(1)+'KB';console.log(`${r.name.padEnd(40)} ${oszS.padStart(8)} ${cszS.padStart(8)} ${(r.ratio||'?').padStart(8)} ${'✅'.padStart(6)} ${r.compUrl}`)}
else{console.log(`${r.name.padEnd(40)} ${oszS.padStart(8)} ${'—'.padStart(8)} ${'—'.padStart(8)} ${'❌'.padStart(6)}`)}}
console.log(`\n📊 ${records.length}장 | ✅ ${pass} 성공 | ❌ ${fail} 실패 | 총 ${(totalSaved/1024).toFixed(0)}KB 절약`);
})();
COMPRESSEOF
압축 매개변수: quality 기본 90 (1-100), 로컬 sharp 불필요, 서버 측 압축으로 직접 전송.
3단계: 로컬에 다운로드 (사용자 요청 시)
압축을 다시 실행하지 마세요! 압축 결과의 CDN URL이 콘솔에 출력되었습니다. curl로 직접 다운로드:
curl -o <output directory>/<filename> "<CDN URL>"
- ✅ 성공: 압축된 CDN URL을 바로 사용 가능
- ⚠️ 5MB 초과: 서버 제한을 초과한 파일, 수동 처리 필요
- ❌ 실패: 한 번 재시도
반환 필드 참조
| 필드 | 타입 | 설명 |
|---|---|---|
originalSize | number | 원본 크기 (bytes) |
compressedSize | number | 압축 후 크기 (bytes) |
ratio | string | 압축률, 예: "71.5%" |
compressedUrl | string | CDN 주소 |
일반 오류 참조
| 오류 현상 | 원인 | 올바른 조치 |
|---|---|---|
MISSING_API_KEY | apiKey 누락 | 반드시 전달 |
API_AUTH_FAILED | API Key 무효 | .mcp.json의 Key 확인 |
-32602 Invalid request parameters | notifications/initialized 미전송 | 반드시 세 단계: init → notified → call |
406 Not Acceptable | Accept 헤더 누락 | application/json과 text/event-stream 모두 명시 |
| 5MB 초과 파일 건너뜀 | 서버 제한 | 사용자에게 수동 처리 안내 |
금지 사항
- ❌ 로컬 압축 하지 말 것 (서버 스마트 압축, 로컬 sharp 불필요)
- ❌
output매개변수 전달하지 말 것 (존재하지 않음) - ❌
apiKey매개변수 생략하지 말 것 - ❌
notifications/initialized단계 생략하지 말 것 - ❌ 임시 파일 쓰지 말 것 (heredoc 직접 stdin, 순수 메모리 실행)
- ❌ 과정을 여러 번의 Bash 호출로 나누지 말 것 (한 번의
node << 'COMPRESSEOF'로 처리) - ❌ 백슬래시 경로 사용하지 말 것, 항상 슬래시 사용 (
d:/path)