From 9c79ce2da30b10179fb5e23b8a3c5430dc795fb4 Mon Sep 17 00:00:00 2001 From: jeonsworld Date: Wed, 16 Sep 2026 19:54:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20RAG=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=8D=94=20MCP=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/marketplace.json | 2 +- .github/workflows/validate.yml | 2 + CONTRIBUTING.md | 10 +- README.md | 2 +- plugins/ennoia/.claude-plugin/plugin.json | 2 +- plugins/ennoia/.codex-plugin/plugin.json | 2 +- plugins/ennoia/.mcp.json | 7 + plugins/ennoia/mcp.json | 12 +- plugins/ennoia/mcp/file-uploader.mjs | 183 ++++++++++++++++ plugins/ennoia/plugin.json | 2 +- .../ennoia/skills/ennoia-knowledge/SKILL.md | 5 +- .../ennoia-knowledge/references/uploads.md | 20 +- scripts/sync_manifests.py | 15 +- scripts/validate.py | 13 +- tests/test_file_uploader.mjs | 199 ++++++++++++++++++ tests/test_validation.py | 18 ++ 16 files changed, 469 insertions(+), 25 deletions(-) create mode 100644 plugins/ennoia/mcp/file-uploader.mjs create mode 100644 tests/test_file_uploader.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e4c9a14..3bbc87e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "ennoia", "source": "./plugins/ennoia", "description": "Ennoia에서 에이전트 생성, 문서 지식 연결, App 실행, 운영 진단과 제품 피드백을 수행합니다.", - "version": "1.3.0" + "version": "1.4.0" } ] } diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ea8de65..d4b6e75 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -26,6 +26,8 @@ jobs: run: python scripts/sync_manifests.py --check - name: Validate packaged paths, skills and MCP contracts run: python scripts/validate.py + - name: Local file uploader regression tests + run: node --test tests/test_file_uploader.mjs - name: Regression tests run: python -m unittest discover -s tests -v - name: Check whitespace diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fd91cc..4f07df7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ - 사용자-facing 문서와 Skill은 간결한 한국어로 작성하고 API 식별자는 그대로 사용합니다. - Skill `description`은 발동 조건을 명확하게 쓰고, 특정 업무의 상세 절차는 필요할 때 읽는 reference로 분리합니다. - 프로젝트·모델·배포 범위와 기존 사용자 승인을 보존합니다. 일반 작업마다 새로운 승인 단계를 추가하지 않습니다. -- Credential을 받는 별도 로컬 script, global hook, host 설정 덮어쓰기, 직접 backend 우회 호출을 추가하지 않습니다. +- OAuth credential을 받는 별도 로컬 script, global hook, host 설정 덮어쓰기, 직접 backend 우회 호출을 추가하지 않습니다. 패키지의 local file uploader는 remote prepare가 발급한 1회용 upload header만 받아 지정된 Ennoia proxy로 전송합니다. - Input schema와 실제 결과는 현재 MCP가 기준입니다. `tests/fixtures/ennoia-tools.json`은 작성 시점의 tool-name 회귀 검사 자료이며 runtime schema를 대체하지 않습니다. MCP 계약 변경 시 확인한 server revision과 snapshot을 함께 갱신합니다. - Skill/reference는 `plugins/ennoia` 내부에서만 상대 경로로 참조합니다. ZIP 생성과 host별 Skill 복제는 필요하지 않습니다. @@ -18,10 +18,11 @@ .claude-plugin/marketplace.json # Claude Marketplace plugins/ennoia/ plugin.json # Portable metadata 원본 - mcp.json # Remote MCP 설정 원본 + mcp.json # Remote·local MCP 설정 원본 .codex-plugin/plugin.json # Codex 호환 manifest .claude-plugin/plugin.json # Claude manifest .mcp.json # 두 host의 호환 MCP 설정 + mcp/file-uploader.mjs # Node.js local file uploader skills/ # 공유 Skill 원본 8개 references/ # 공통 응답 해석·표시 규칙 assets/ # 공식 아이콘·로고, 다크 모드용 로고 @@ -30,7 +31,7 @@ tests/ # 배포 회귀 검증과 tool 계약 snapsh evals/ # 모델 동작 검증 시나리오 ``` -두 Marketplace는 동일한 `./plugins/ennoia`를 설치합니다. Marketplace와 Plugin 이름은 모두 `ennoia`이며 설치 식별자는 `ennoia@ennoia`입니다. MCP는 `https://mcp.ennoia.so/mcp`에 원격 연결합니다. Plugin 밖의 파일이나 symlink를 참조하지 않아 host cache로 복사해도 필요한 자료가 유지됩니다. 사용자는 Python이나 로컬 서버를 실행할 필요가 없습니다. +두 Marketplace는 동일한 `./plugins/ennoia`를 설치합니다. Marketplace와 Plugin 이름은 모두 `ennoia`이며 설치 식별자는 `ennoia@ennoia`입니다. MCP는 `https://mcp.ennoia.so/mcp`에 원격 연결합니다. Plugin 밖의 파일이나 symlink를 참조하지 않아 host cache로 복사해도 필요한 자료가 유지됩니다. 로컬 파일 업로드에는 Node.js가 필요하며 host가 패키지의 local MCP를 시작합니다. 사용자가 별도 서버를 수동 실행할 필요는 없습니다. ## 변경하기 @@ -51,6 +52,7 @@ python3 scripts/sync_manifests.py python3 scripts/sync_manifests.py --check python3 scripts/validate.py python3 -m unittest discover -s tests -v +node --test tests/test_file_uploader.mjs python3 scripts/validate_results.py git diff --check ``` @@ -66,7 +68,7 @@ claude plugin validate --strict .claude-plugin/marketplace.json claude plugin validate --strict plugins/ennoia ``` -Claude validator의 성공은 Skill 행동이나 OAuth 성공을 증명하지 않습니다. 설치 후 실제 inventory에서 Skill 8개와 Ennoia Remote MCP 1개를 확인하고, 읽기 호출로 인증과 project context를 확인합니다. Codex는 해당 버전의 plugin validator 또는 native `plugin list`/`plugin add`로 설치를 검증합니다. +Claude validator의 성공은 Skill 행동이나 OAuth 성공을 증명하지 않습니다. 설치 후 실제 inventory에서 Skill 8개, Ennoia Remote MCP 1개, local file uploader MCP 1개를 확인하고, 읽기 호출로 인증과 project context를 확인합니다. Codex는 해당 버전의 plugin validator 또는 native `plugin list`/`plugin add`로 설치를 검증합니다. 문서 본문 문구를 정규식으로 맞추는 테스트 대신 broken reference, package 밖 경로, manifest version drift, MCP credential 포함, 미확인 tool 같은 배포 실패를 검사합니다. 모델 동작은 `evals/scenarios.json`을 별도로 사용합니다. diff --git a/README.md b/README.md index 389aade..20e1445 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **대화로 Ennoia 에이전트를 만들고, 문서를 연결하고, 업무를 실행하세요.** -Codex App · Claude App · Codex CLI · Claude Code에서 같은 플러그인을 사용합니다. 작업별 Skill 8개와 Ennoia MCP를 함께 설치하며, 별도 서버 실행이나 ZIP 업로드는 필요하지 않습니다. +Codex App · Claude App · Codex CLI · Claude Code에서 같은 플러그인을 사용합니다. 작업별 Skill 8개, Ennoia 원격 MCP, 로컬 파일 업로더 MCP를 함께 설치합니다. 로컬 파일 전송은 Node.js를 실행할 수 있는 host에서 지원하며, 업로더는 Plugin이 시작합니다. [설치](#설치) · [사용 예시](#사용-예시) · [제품 피드백](#제품-피드백) · [업데이트](#업데이트) · [문제 해결](#문제-해결) · [릴리스 내역](https://github.com/wanteddev/ennoia-plugin/releases) diff --git a/plugins/ennoia/.claude-plugin/plugin.json b/plugins/ennoia/.claude-plugin/plugin.json index e9db30e..5cf4a14 100644 --- a/plugins/ennoia/.claude-plugin/plugin.json +++ b/plugins/ennoia/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ennoia", - "version": "1.3.0", + "version": "1.4.0", "description": "Ennoia에서 에이전트 생성, 문서 지식 연결, App 실행, 운영 진단과 제품 피드백을 수행합니다.", "author": { "name": "Wantedlab", diff --git a/plugins/ennoia/.codex-plugin/plugin.json b/plugins/ennoia/.codex-plugin/plugin.json index 8a06c5c..8109a4b 100644 --- a/plugins/ennoia/.codex-plugin/plugin.json +++ b/plugins/ennoia/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ennoia", - "version": "1.3.0", + "version": "1.4.0", "description": "Ennoia에서 에이전트 생성, 문서 지식 연결, App 실행, 운영 진단과 제품 피드백을 수행합니다.", "author": { "name": "Wantedlab", diff --git a/plugins/ennoia/.mcp.json b/plugins/ennoia/.mcp.json index 044a692..ad0c82d 100644 --- a/plugins/ennoia/.mcp.json +++ b/plugins/ennoia/.mcp.json @@ -3,6 +3,13 @@ "ennoia": { "type": "http", "url": "https://mcp.ennoia.so/mcp" + }, + "ennoia-file-uploader": { + "command": "node", + "cwd": ".", + "args": [ + "./mcp/file-uploader.mjs" + ] } } } diff --git a/plugins/ennoia/mcp.json b/plugins/ennoia/mcp.json index 4de44e9..813b418 100644 --- a/plugins/ennoia/mcp.json +++ b/plugins/ennoia/mcp.json @@ -1,6 +1,16 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", "mcpServers": { - "ennoia": {"type": "streamable-http", "url": "https://mcp.ennoia.so/mcp"} + "ennoia": { + "type": "streamable-http", + "url": "https://mcp.ennoia.so/mcp" + }, + "ennoia-file-uploader": { + "command": "node", + "cwd": ".", + "args": [ + "./mcp/file-uploader.mjs" + ] + } } } diff --git a/plugins/ennoia/mcp/file-uploader.mjs b/plugins/ennoia/mcp/file-uploader.mjs new file mode 100644 index 0000000..513fbf7 --- /dev/null +++ b/plugins/ennoia/mcp/file-uploader.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +// 로컬 파일 byte는 JSON-RPC를 거치지 않고 HTTPS PUT stream으로 전송한다. +import { constants } from 'node:fs'; +import { lstat, open } from 'node:fs/promises'; +import { extname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { request as httpsRequest } from 'node:https'; +import { Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +const MAX_FILE_SIZE = 104857600; +const MAX_RESPONSE_SIZE = 65536; +const MAX_RPC_SIZE = 65536; +const UPLOAD_TIMEOUT_MS = 120000; +const EXTENSIONS = new Set(['.csv', '.txt', '.md', '.pdf', '.docx', '.pptx', '.xlsx', '.xls', '.zip']); +const HOSTS = new Set(['mcp.ennoia.so', 'dev-mcp-server.ennoia.so']); +const REQUIRED_HEADERS = new Set(['content-length', 'content-type', 'x-ennoia-upload-token']); +class UploadError extends Error {} +const fail = code => { throw new UploadError(code); }; + +export const uploadTool = { + name: 'upload_ennoia_rag_file', + description: 'prepare_rag_document_upload가 반환한 upload_url과 exact headers로 로컬 파일을 PUT합니다. local_path만 지정하고 파일 byte/base64는 MCP JSON에 넣지 않습니다. 성공 후 collection_code/file_seq로 처리 상태를 확인하세요.', + inputSchema: { + type: 'object', additionalProperties: false, + properties: { + local_path: { type: 'string', description: '사용자가 업로드를 요청한 로컬 파일 경로' }, + upload_url: { type: 'string', description: 'prepare_rag_document_upload 응답의 upload_url' }, + headers: { type: 'object', additionalProperties: { type: 'string' }, description: 'prepare 응답의 headers를 수정 없이 전달' }, + }, + required: ['local_path', 'upload_url', 'headers'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, +}; + +function validateArguments(args) { + if (!args || typeof args !== 'object' || Array.isArray(args) + || Object.keys(args).some(key => !['local_path', 'upload_url', 'headers'].includes(key)) + || typeof args.local_path !== 'string' || !args.local_path || args.local_path.includes('\0')) fail('ARGUMENTS_INVALID'); + if (typeof args.upload_url !== 'string') fail('UPLOAD_URL_INVALID'); + let url; + try { url = new URL(args.upload_url); } catch { fail('UPLOAD_URL_INVALID'); } + // URL parser의 경로 정규화, escape, query를 통한 allowlist 우회를 차단한다. + if (url.protocol !== 'https:' || !HOSTS.has(url.hostname) || url.port || url.username || url.password + || url.search || url.hash || !/^\/rag\/uploads\/[A-Za-z0-9_-]{1,128}$/.test(url.pathname) + || args.upload_url !== url.href) fail('UPLOAD_URL_INVALID'); + if (!EXTENSIONS.has(extname(args.local_path).toLowerCase())) fail('FILE_TYPE_UNSUPPORTED'); + if (!args.headers || typeof args.headers !== 'object' || Array.isArray(args.headers)) fail('HEADERS_INVALID'); + const names = new Set(); + let length; + for (const [key, value] of Object.entries(args.headers)) { + const name = key.toLowerCase(); + if (!REQUIRED_HEADERS.has(name) || names.has(name) || typeof value !== 'string' + || !value || /[^\x20-\x7e]|,/.test(value)) fail('HEADERS_INVALID'); + names.add(name); + if (name === 'content-length') length = value; + } + if (names.size !== REQUIRED_HEADERS.size || !/^[1-9][0-9]*$/.test(length)) fail('HEADERS_INVALID'); + return { url, length }; +} + +async function readResponse(response) { + if (response.statusCode >= 300 && response.statusCode < 400) { + response.destroy(); fail('REDIRECT_REJECTED'); + } + if (!(response.statusCode >= 200 && response.statusCode < 300)) { + response.destroy(); fail(`UPLOAD_HTTP_${response.statusCode || 0}`); + } + let size = 0; + const chunks = []; + for await (const chunk of response) { + size += chunk.length; + if (size > MAX_RESPONSE_SIZE) { response.destroy(); fail('RESPONSE_TOO_LARGE'); } + chunks.push(chunk); + } + // 응답 본문은 token/URL이 반사될 수 있으므로 안전한 식별자만 반환한다. + const result = { uploaded: true, status_code: response.statusCode }; + try { + const data = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const seq = data?.data?.file_seq ?? data?.file_seq; + if (Number.isSafeInteger(seq) && seq > 0) result.file_seq = seq; + } catch { /* 비 JSON 성공 응답은 HTTP 전송 성공만 기록한다. */ } + return result; +} + +export async function uploadFile(args, request = httpsRequest) { + const { url, length } = validateArguments(args); + let file; + try { + const before = await lstat(args.local_path); + if (!before.isFile() || before.isSymbolicLink()) fail('FILE_INVALID'); + // NOFOLLOW와 fd 검증으로 lstat/open 사이의 symlink 교체도 거부한다. + file = await open(args.local_path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const stat = await file.stat(); + if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) fail('FILE_INVALID'); + if (stat.size < 1 || stat.size > MAX_FILE_SIZE) fail('FILE_SIZE_INVALID'); + if (length !== String(stat.size)) fail('CONTENT_LENGTH_MISMATCH'); + let req, source, timer; + try { + const response = new Promise((resolveResponse, rejectResponse) => { + req = request(url, { method: 'PUT', headers: args.headers }, incoming => { + readResponse(incoming).then(resolveResponse, rejectResponse); + }); + req.once('error', () => rejectResponse(new UploadError('UPLOAD_NETWORK_ERROR'))); + timer = setTimeout(() => { + rejectResponse(new UploadError('UPLOAD_TIMEOUT')); + req.destroy(); + }, UPLOAD_TIMEOUT_MS); + }); + let sent = 0; + const counter = new Transform({ + transform(chunk, encoding, callback) { sent += chunk.length; callback(null, chunk); }, + flush(callback) { callback(sent === stat.size ? null : new UploadError('FILE_SIZE_CHANGED')); }, + }); + source = file.createReadStream({ autoClose: false, start: 0, end: stat.size - 1 }); + const [result] = await Promise.all([response, pipeline(source, counter, req)]); + if ((await file.stat()).size !== stat.size) fail('FILE_SIZE_CHANGED'); + return result; + } catch (error) { + if (error instanceof UploadError) throw error; + fail('UPLOAD_NETWORK_ERROR'); + } finally { + clearTimeout(timer); + source?.destroy(); + req?.destroy(); + } + } catch (error) { + if (error instanceof UploadError) throw error; + fail('FILE_INVALID'); + } finally { + await file?.close().catch(() => {}); + } +} + +async function handleRpc(message) { + const id = message?.id ?? null; + const error = (code, text) => ({ jsonrpc: '2.0', id, error: { code, message: text } }); + if (!message || Array.isArray(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') return error(-32600, 'Invalid Request'); + if (!Object.hasOwn(message, 'id')) return undefined; + const reply = result => ({ jsonrpc: '2.0', id, result }); + if (message.method === 'initialize') return reply({ + protocolVersion: ['2024-11-05', '2025-03-26', '2025-06-18'].includes(message.params?.protocolVersion) ? message.params.protocolVersion : '2025-06-18', + capabilities: { tools: {} }, serverInfo: { name: 'ennoia-file-uploader', version: '1.4.0' }, + }); + if (message.method === 'ping') return reply({}); + if (message.method === 'tools/list') return reply({ tools: [uploadTool] }); + if (message.method !== 'tools/call') return error(-32601, 'Method not found'); + if (message.params?.name !== uploadTool.name) return error(-32602, 'Unknown tool'); + try { + const result = await uploadFile(message.params.arguments); + return reply({ content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result }); + } catch (err) { + return reply({ isError: true, content: [{ type: 'text', text: err instanceof UploadError ? err.message : 'UPLOAD_FAILED' }] }); + } +} + +export async function serve(input = process.stdin, output = process.stdout) { + let pending = '', oversized = false; + const write = value => { if (value) output.write(JSON.stringify(value) + '\n'); }; + input.setEncoding('utf8'); + for await (const chunk of input) { + for (const [index, part] of chunk.split('\n').entries()) { + if (index > 0) { + if (oversized) write({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Request too large' } }); + else if (pending.trim()) { + let message; + try { message = JSON.parse(pending); } + catch { write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); } + if (message !== undefined) write(await handleRpc(message)); + } + pending = ''; oversized = false; + } + if (!oversized) { + pending += part; + if (Buffer.byteLength(pending) > MAX_RPC_SIZE) { pending = ''; oversized = true; } + } + } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + serve().catch(() => { process.exitCode = 1; }); +} diff --git a/plugins/ennoia/plugin.json b/plugins/ennoia/plugin.json index 2b497ac..367e024 100644 --- a/plugins/ennoia/plugin.json +++ b/plugins/ennoia/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "ennoia", - "version": "1.3.0", + "version": "1.4.0", "description": "Ennoia에서 에이전트 생성, 문서 지식 연결, App 실행, 운영 진단과 제품 피드백을 수행합니다.", "author": { "name": "Wantedlab", diff --git a/plugins/ennoia/skills/ennoia-knowledge/SKILL.md b/plugins/ennoia/skills/ennoia-knowledge/SKILL.md index 6024b78..b9752a8 100644 --- a/plugins/ennoia/skills/ennoia-knowledge/SKILL.md +++ b/plugins/ennoia/skills/ennoia-knowledge/SKILL.md @@ -14,8 +14,9 @@ description: "Ennoia 지식 컬렉션에 문서·파일·URL을 추가하거나 Ennoia MCP의 현재 input schema를 확인하고 `get_current_ennoia_project`로 대상 이름을 알린다. 생성·업로드·실행·변경은 사용자가 정한 단일 프로젝트에서 수행한다. `auto_selected`이면 명시 선택을 먼저 확보한다. 1. 대상 프로젝트와 정확한 `collection_code`를 이미 알면 그 code로 `get_rag_collection`을 우선 조회한다. 표시 이름이나 목록을 다시 찾지 않는다. code를 모를 때만 `list_multi_agent_rag_collections`로 query 검색하고 필요한 단건을 읽는다. 적합한 기존 컬렉션을 사용한다. 신규가 요청됐거나 필요할 때 `create_rag_collection`을 사용한다. -2. `get_rag_capabilities`로 허용 확장자·크기·업로드 경로를 확인한다. 일반 텍스트는 `upload_rag_text_document`, 로컬 binary는 `prepare_rag_document_upload`, 공개 HTTPS 문서는 `import_rag_document_from_url`을 선택한다. -3. binary/URL의 추가 제약은 [업로드 경로](references/uploads.md)를 읽는다. MCP 서버에 로컬 파일 경로를 넘기는 것만으로 업로드되지는 않는다. +2. `get_rag_capabilities` 응답의 `allowed_extensions`와 `max_file_size_bytes`로 허용 확장자·크기를 확인하고 업로드 경로를 선택한다. 대화에 제공된 텍스트 내용은 `upload_rag_text_document`, 공개 HTTPS 문서는 `import_rag_document_from_url`을 사용한다. 사용자 PC의 파일은 확장자와 관계없이 아래 local upload 순서를 따른다. +3. 로컬 파일은 [업로드 경로](references/uploads.md)를 읽고, 이름·실제 byte 크기·content type만 확인해 `prepare_rag_document_upload(collection_code, file_name, size_bytes, content_type)`를 호출한다. 성공 응답의 `upload_url`과 `headers`를 그대로 `upload_ennoia_rag_file(local_path, upload_url, headers)`에 전달한다. 로컬 tool이 파일을 디스크에서 읽어 PUT한다. 파일 byte·base64·전체 본문을 모델 context 또는 MCP JSON에 넣지 않는다. +4. 원격 tool에는 `collection_code`, 파일 조회에는 `file_seq`를 사용한다. `collection_id`·`file_id`·`file_data`는 이 계약의 인자가 아니다. 동일한 프로젝트에서 등록과 상태 확인을 이어간다. ## 처리 완료와 연결 diff --git a/plugins/ennoia/skills/ennoia-knowledge/references/uploads.md b/plugins/ennoia/skills/ennoia-knowledge/references/uploads.md index 9673124..42c10e3 100644 --- a/plugins/ennoia/skills/ennoia-knowledge/references/uploads.md +++ b/plugins/ennoia/skills/ennoia-knowledge/references/uploads.md @@ -2,14 +2,22 @@ | 입력 | 경로 | 확인할 것 | | --- | --- | --- | -| 텍스트 내용 | `upload_rag_text_document` | 현재 정책의 크기·UTF-8·file_name | -| 로컬 PDF/Office 등 binary | `prepare_rag_document_upload` | 반환된 method·upload URL·필수 header·만료·실제 파일 byte | +| 대화에 제공된 텍스트 내용 | `upload_rag_text_document` | 현재 정책의 크기·UTF-8·file_name | +| 사용자 PC의 로컬 파일 | `prepare_rag_document_upload` → `upload_ennoia_rag_file` → status | 파일명·실제 size_bytes·content_type, 반환 URL과 exact headers | | 공개 HTTPS 문서 | `import_rag_document_from_url` | 인증 없는 공개 주소, 허용 형식과 크기 | -One-time upload는 준비 tool 호출 후 host의 파일 전송 기능으로 반환된 URL에 실제 byte를 `PUT`해야 한다. upload URL을 일반 보고서·로그·공유 링크로 출력하지 않는다. host에서 byte 전송이 불가능하면 Ennoia의 파일 업로드 화면을 통한 업로드가 필요하다고 정확히 알리고, 완료 후 같은 컬렉션에서 상태 확인을 이어간다. +## 로컬 파일 전송 계약 -URL import에 cookie·Authorization header·signed query를 넣거나 내부/private 주소를 제공하지 않는다. 보호된 문서를 public으로 공개해서 이 경로에 맞추지 않는다. 사용자가 제공한 로컬 파일은 one-time upload 경로를 사용한다. +1. 정확한 `collection_code`를 알면 `get_rag_collection(collection_code)`로 조회한다. code를 모를 때만 컬렉션 목록에서 찾는다. 표시 이름을 code로 가정하지 않는다. +2. `get_rag_capabilities` 응답의 `allowed_extensions`와 `max_file_size_bytes`를 확인한다. 파일 내용은 읽지 않고 파일명, 실제 byte 크기, content type을 확인한다. 로컬 uploader의 허용 형식은 `.csv`, `.txt`, `.md`, `.pdf`, `.docx`, `.pptx`, `.xlsx`, `.xls`, `.zip`이고 크기는 1~104857600 bytes이다. 서버의 `max_file_size_bytes`가 더 작으면 그 제한을 따른다. 일반 파일만 지원하며 symlink는 지원하지 않는다. +3. 현재 원격 schema에 맞춰 `prepare_rag_document_upload`에 `collection_code`, `file_name`, `size_bytes`, `content_type` 및 동일한 `project_scope`를 전달한다. 로컬 경로를 원격 prepare에 전달하지 않는다. +4. prepare가 성공하고 `method=PUT`인 경우 로컬 `upload_ennoia_rag_file`에 정확히 세 인자를 전달한다: `local_path`는 `/tmp/employee-handbook.pdf`, `upload_url`은 prepare 성공 응답의 URL, `headers`는 prepare 성공 응답의 header object 그대로다. `headers`를 문자열로 바꾸지 않는다. OAuth Bearer·Cookie를 추가하거나 `Content-Length`를 바꾸지 않는다. 파일 byte·base64·본문·`file_data`를 MCP JSON이나 모델 context에 넣지 않는다. +5. 로컬 tool은 disk stream으로 1회 PUT하고 redirect를 거부한다. 대상은 HTTPS `mcp.ennoia.so` 또는 `dev-mcp-server.ennoia.so`의 `/rag/uploads/{opaque-id}`만 허용한다. upload URL·token·headers를 로그·보고서·공유 링크로 출력하지 않는다. +6. PUT 결과에 `file_seq`가 있으면 같은 `collection_code`로 `get_rag_file_status`를 조회한다. 없으면 `list_rag_files(collection_code)`로 해당 파일의 `file_seq`를 찾아 조회한다. 원격 식별자는 `collection_code`와 `file_seq`이며 `collection_id`·`file_id`로 바꾸지 않는다. 같은 `project_scope`를 유지한다. +7. `processing_state=ready`와 `ready_for_agent=true`를 함께 확인해야 준비 완료다. 업로드 ticket 발급, HTTP 전송 성공, 인덱싱 완료를 구분한다. timeout이면 먼저 파일 목록·상태를 확인하고 중복 업로드를 피한다. -업로드 ticket 발급, HTTP 전송 성공, 인덱싱 완료는 서로 다른 단계다. 마지막은 `get_rag_file_status`의 readiness로 확인한다. 오류가 timeout이라면 먼저 파일 목록·상태를 조회해 중복 업로드를 피한다. +로컬 uploader가 설치되지 않았거나 host가 Node 실행·파일 접근을 지원하지 않으면 Ennoia 파일 업로드 화면을 안내하고, 완료 후 같은 컬렉션에서 상태 확인을 이어간다. 파일을 base64로 바꾸거나 원격 서버에서 로컬 경로를 읽으려 하지 않는다. -정확한 `collection_code`를 이미 알면 목록 재검색 없이 `get_rag_collection` 단건을 우선 사용한다. 기존 컬렉션을 graph에 연결할 때 목록의 `collection_name`이 null이면 정확한 `collection_code`로 `get_rag_collection`을 단건 조회한다. 단건에도 index 이름이 없으면 연결 불가 상태로 알리고 표시 이름·code를 index 이름으로 만들지 않는다. +URL import에 cookie·Authorization header·signed query를 넣거나 내부/private 주소를 제공하지 않는다. 보호된 문서를 public으로 공개해서 이 경로에 맞추지 않는다. 사용자가 제공한 로컬 파일은 위 local upload 경로를 사용한다. + +기존 컬렉션을 graph에 연결할 때 `collection_name`이 null이면 정확한 `collection_code`로 `get_rag_collection`을 단건 조회한다. 단건에도 index 이름이 없으면 연결 불가 상태로 알리고 표시 이름·code를 index 이름으로 만들지 않는다. diff --git a/scripts/sync_manifests.py b/scripts/sync_manifests.py index 5aff679..52fe423 100644 --- a/scripts/sync_manifests.py +++ b/scripts/sync_manifests.py @@ -13,11 +13,16 @@ def outputs(root: Path) -> dict[Path, dict]: manifest = json.loads((plugin / "plugin.json").read_text(encoding="utf-8")) mcp = json.loads((plugin / "mcp.json").read_text(encoding="utf-8")) identity = {k: v for k, v in manifest.items() if k not in {"$schema", "extensions"}} - servers = {} - for name, config in mcp["mcpServers"].items(): - if config["type"] != "streamable-http": - raise ValueError("Ennoia 배포는 Remote Streamable HTTP만 지원합니다.") - servers[name] = {**config, "type": "http"} + expected_servers = { + "ennoia": {"type": "streamable-http", "url": "https://mcp.ennoia.so/mcp"}, + "ennoia-file-uploader": {"command": "node", "cwd": ".", "args": ["./mcp/file-uploader.mjs"]}, + } + if mcp.get("mcpServers") != expected_servers: + raise ValueError("Ennoia 배포는 지정한 remote MCP와 local file uploader만 지원합니다.") + servers = { + **expected_servers, + "ennoia": {**expected_servers["ennoia"], "type": "http"}, + } return { plugin / ".claude-plugin/plugin.json": identity, plugin / ".codex-plugin/plugin.json": { diff --git a/scripts/validate.py b/scripts/validate.py index fddcef1..7ac5d26 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -11,7 +11,7 @@ TOOL_PATTERN = re.compile(r"`((?:get|set|list|create|update|delete|clone|rename|share|start|continue|stop|deploy|invoke|add|connect|disconnect|import|upload|prepare|retry|validate|test|save|switch|logout)_[a-z_]+)`") LINK_PATTERN = re.compile(r"\[[^\]]+\]\(([^)]+)\)") # API field가 동사 prefix를 공유해도 tool 이름으로 취급하지 않는다. -FIELD_IDENTIFIERS = {"test_id", "deploy_version"} +FIELD_IDENTIFIERS = {"test_id", "deploy_version", "upload_url"} def read_json(path: Path) -> dict: @@ -57,9 +57,16 @@ def validate_repository(root: Path) -> list[str]: errors.extend(validate_brand_assets(plugin, interface, ("composerIcon", "logo", "logoDark"), "brandColor")) for filename, transport in (("mcp.json", "streamable-http"), (".mcp.json", "http")): mcp = read_json(plugin / filename) - if mcp.get("mcpServers") != {"ennoia": {"type": transport, "url": "https://mcp.ennoia.so/mcp"}}: + expected_servers = { + "ennoia": {"type": transport, "url": "https://mcp.ennoia.so/mcp"}, + "ennoia-file-uploader": {"command": "node", "cwd": ".", "args": ["./mcp/file-uploader.mjs"]}, + } + if mcp.get("mcpServers") != expected_servers: errors.append(f"MCP 설정 오류 또는 credential 포함: {filename}") + if not (plugin / "mcp/file-uploader.mjs").is_file(): + errors.append("MCP local file uploader 누락") + a = read_json(root / ".agents/plugins/marketplace.json") b = read_json(root / ".claude-plugin/marketplace.json") if a.get("name") != "ennoia" or b.get("name") != "ennoia": @@ -76,6 +83,8 @@ def validate_repository(root: Path) -> list[str]: errors.append("marketplace 설치·인증 policy 오류") tools = set(read_json(root / "tests/fixtures/ennoia-tools.json")["tools"]) + # Remote tool fixture는 유지하고 plugin이 제공하는 local tool을 추가한다. + tools.add("upload_ennoia_rag_file") skills = sorted((plugin / "skills").glob("*/SKILL.md")) if not skills: errors.append("Skill이 없습니다") diff --git a/tests/test_file_uploader.mjs b/tests/test_file_uploader.mjs new file mode 100644 index 0000000..7467408 --- /dev/null +++ b/tests/test_file_uploader.mjs @@ -0,0 +1,199 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm, symlink, mkdir, truncate } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable, Writable } from 'node:stream'; +import { spawn } from 'node:child_process'; +import { uploadFile } from '../plugins/ennoia/mcp/file-uploader.mjs'; + +const server = new URL('../plugins/ennoia/mcp/file-uploader.mjs', import.meta.url); +const url = 'https://mcp.ennoia.so/rag/uploads/opaque-id_123'; +const token = 'secret-upload-token'; +async function fixture(t, name = 'document.pdf', bytes = Buffer.alloc(160000, 42)) { + const dir = await mkdtemp(join(tmpdir(), 'ennoia-upload-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const local_path = join(dir, name); + await writeFile(local_path, bytes); + return { local_path, upload_url: url, headers: { + 'X-Ennoia-Upload-Token': token, 'Content-Type': 'application/pdf', 'Content-Length': String(bytes.length), + } }; +} +function transport({ status = 200, body = '{"ok":true,"data":{"file_seq":7}}', failure, early = false } = {}) { + const calls = []; + const request = (target, options, onResponse) => { + const call = { target, options, chunks: [] }; + calls.push(call); + const respond = () => { + if (failure) { req.destroy(new Error(failure)); return; } + const response = Readable.from([Buffer.from(body)]); + response.statusCode = status; + response.headers = { location: 'https://evil.example/steal' }; + onResponse(response); + }; + const req = new Writable({ + autoDestroy: false, + write(chunk, encoding, callback) { call.chunks.push(Buffer.from(chunk)); setImmediate(callback); }, + final(callback) { callback(); if (!early) setImmediate(respond); }, + }); + req.setTimeout = () => req; + if (early) setImmediate(respond); + return req; + }; + return { request, calls }; +} + +test('stdio initializes, ignores notification, lists exact tool and returns safe tool errors', async () => { + const child = spawn(process.execPath, [server.pathname], { stdio: ['pipe', 'pipe', 'pipe'] }); + let output = '', errors = ''; + child.stdout.on('data', chunk => output += chunk); + child.stderr.on('data', chunk => errors += chunk); + const messages = [ + { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1' } } }, + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { jsonrpc: '2.0', id: 2, method: 'tools/list' }, + { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'upload_ennoia_rag_file', arguments: { local_path: '/missing.pdf', upload_url: url, headers: {} } } }, + { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'unknown', arguments: {} } }, + ]; + child.stdin.end(messages.map(x => JSON.stringify(x)).join('\n') + '\n'); + assert.equal(await new Promise(resolve => child.on('close', resolve)), 0); + assert.equal(errors, ''); + const replies = output.trim().split('\n').map(JSON.parse); + assert.equal(replies.length, 4); + assert.equal(replies[0].result.protocolVersion, '2025-03-26'); + assert.deepEqual(replies[0].result.capabilities, { tools: {} }); + const [tool] = replies[1].result.tools; + assert.equal(tool.name, 'upload_ennoia_rag_file'); + assert.deepEqual(tool.inputSchema.required, ['local_path', 'upload_url', 'headers']); + assert.deepEqual(Object.keys(tool.inputSchema.properties), ['local_path', 'upload_url', 'headers']); + assert.equal(replies[2].result.isError, true); + assert.equal(replies[3].error.code, -32602); +}); + +test('streams actual disk chunks by PUT with exact headers and returns safe file identity', async t => { + const args = await fixture(t); + const network = transport(); + assert.deepEqual(await uploadFile(args, network.request), { uploaded: true, status_code: 200, file_seq: 7 }); + assert.equal(network.calls.length, 1); + const call = network.calls[0]; + assert.equal(String(call.target), url); + assert.equal(call.options.method, 'PUT'); + assert.deepEqual(call.options.headers, args.headers); + assert.ok(call.chunks.length > 1, '파일 전체를 한 번에 메모리로 읽지 않음'); + assert.deepEqual(Buffer.concat(call.chunks), Buffer.alloc(160000, 42)); +}); + +for (const invalid of [ + 'http://mcp.ennoia.so/rag/uploads/x', 'https://evil.example/rag/uploads/x', + 'https://mcp.ennoia.so.evil.example/rag/uploads/x', 'https://mcp.ennoia.so:444/rag/uploads/x', + 'https://user:password@mcp.ennoia.so/rag/uploads/x', 'https://mcp.ennoia.so/mcp', + 'https://mcp.ennoia.so/rag/uploads/x/more', 'https://mcp.ennoia.so/rag/uploads/%2e%2e', + 'https://mcp.ennoia.so/rag/uploads/x?token=secret', 'https://mcp.ennoia.so/rag/uploads/x#fragment', +]) test(`rejects destination ${invalid}`, async t => { + const args = await fixture(t); const network = transport(); + await assert.rejects(uploadFile({ ...args, upload_url: invalid }, network.request), /UPLOAD_URL_INVALID/); + assert.equal(network.calls.length, 0); +}); + +test('accepts dev endpoint and all supported extensions', async t => { + for (const suffix of ['csv', 'txt', 'md', 'pdf', 'docx', 'pptx', 'xlsx', 'xls', 'zip']) { + const args = await fixture(t, `document.${suffix}`, Buffer.from('x')); + args.upload_url = 'https://dev-mcp-server.ennoia.so/rag/uploads/x'; + assert.equal((await uploadFile(args, transport().request)).uploaded, true); + } +}); + +test('rejects unsupported suffix, empty and oversized file before network', async t => { + for (const [name, size, code] of [['x.exe', 1, 'FILE_TYPE_UNSUPPORTED'], ['x.pdf', 0, 'FILE_SIZE_INVALID'], ['big.pdf', 104857601, 'FILE_SIZE_INVALID']]) { + const args = await fixture(t, name, Buffer.from('x')); + await truncate(args.local_path, size); + args.headers['Content-Length'] = String(Math.max(1, size)); + const network = transport(); + await assert.rejects(uploadFile(args, network.request), new RegExp(code)); + assert.equal(network.calls.length, 0); + } +}); + +test('rejects symlink and non-regular directory', async t => { + const args = await fixture(t); const network = transport(); + const link = args.local_path + '.pdf'; + await symlink(args.local_path, link); + await assert.rejects(uploadFile({ ...args, local_path: link }, network.request), /FILE_INVALID/); + const directory = args.local_path + '-dir.pdf'; + await mkdir(directory); + await assert.rejects(uploadFile({ ...args, local_path: directory }, network.request), /FILE_INVALID/); + assert.equal(network.calls.length, 0); +}); + +test('rejects mismatch, missing, duplicate and unsafe headers', async t => { + const args = await fixture(t); const network = transport(); + const variants = [ + { ...args.headers, 'Content-Length': '1' }, { ...args.headers, 'Content-Length': '0160000' }, + { ...args.headers, 'content-length': '160000' }, { ...args.headers, Authorization: 'Bearer secret' }, + { ...args.headers, Host: 'evil.example' }, { ...args.headers, 'Transfer-Encoding': 'chunked' }, + { ...args.headers, 'X-Ennoia-Upload-Token': 'bad\r\nvalue' }, {}, + ]; + for (const headers of variants) await assert.rejects(uploadFile({ ...args, headers }, network.request), /HEADERS_INVALID|CONTENT_LENGTH_MISMATCH/); + assert.equal(network.calls.length, 0); +}); + +for (const status of [301, 302, 303, 307, 308]) test(`rejects redirect ${status} without forwarding token`, async t => { + const network = transport({ status }); + await assert.rejects(uploadFile(await fixture(t), network.request), /REDIRECT_REJECTED/); + assert.equal(network.calls.length, 1); +}); + +test('bounds response and suppresses upstream response secrets/errors', async t => { + const args = await fixture(t); + await assert.rejects(uploadFile(args, transport({ body: 'x'.repeat(65537) }).request), /RESPONSE_TOO_LARGE/); + await assert.rejects(uploadFile(args, transport({ status: 500, body: token + url }).request), err => err.message === 'UPLOAD_HTTP_500'); + await assert.rejects(uploadFile(args, transport({ failure: token + url }).request), err => err.message === 'UPLOAD_NETWORK_ERROR'); + const result = await uploadFile(args, transport({ body: JSON.stringify({ data: { file_seq: 9, upload_url: url, token } }) }).request); + assert.deepEqual(result, { uploaded: true, status_code: 200, file_seq: 9 }); +}); + +test('handles early redirect while file stream is still active', async t => { + const network = transport({ status: 307, early: true }); + await assert.rejects(uploadFile(await fixture(t), network.request), /REDIRECT_REJECTED/); + assert.equal(network.calls.length, 1); +}); + +test('accepts exactly 100 MiB with bounded stream chunks', async t => { + const args = await fixture(t, 'boundary.zip', Buffer.from('x')); + await truncate(args.local_path, 104857600); + args.headers['Content-Length'] = '104857600'; + let bytes = 0, largest = 0; + const request = (target, options, onResponse) => new Writable({ + autoDestroy: false, + write(chunk, encoding, callback) { bytes += chunk.length; largest = Math.max(largest, chunk.length); callback(); }, + final(callback) { + callback(); + const response = Readable.from(['{}']); response.statusCode = 200; onResponse(response); + }, + }); + assert.equal((await uploadFile(args, request)).uploaded, true); + assert.equal(bytes, 104857600); + assert.ok(largest <= 65536); +}); + +test('rejects byte payload arguments and sanitizes synchronous network errors', async t => { + const args = await fixture(t); + await assert.rejects(uploadFile({ ...args, file_data: 'secret-bytes' }, transport().request), /ARGUMENTS_INVALID/); + await assert.rejects(uploadFile(args, () => { throw new Error(token + url); }), err => err.message === 'UPLOAD_NETWORK_ERROR'); +}); + +test('stdio recovers after malformed and oversized newline messages without exposing content', async () => { + const child = spawn(process.execPath, [server.pathname], { stdio: ['pipe', 'pipe', 'pipe'] }); + let output = '', errors = ''; + child.stdout.on('data', chunk => output += chunk); + child.stderr.on('data', chunk => errors += chunk); + child.stdin.end('{bad-json\n' + 'x'.repeat(65537) + '\n' + JSON.stringify({ jsonrpc: '2.0', id: 10, method: 'tools/list' }) + '\n'); + assert.equal(await new Promise(resolve => child.on('close', resolve)), 0); + assert.equal(errors, ''); + const replies = output.trim().split('\n').map(JSON.parse); + assert.equal(replies.length, 3); + assert.equal(replies[0].error.code, -32700); + assert.equal(replies[1].error.code, -32600); + assert.equal(replies[2].result.tools[0].name, 'upload_ennoia_rag_file'); + assert.ok(output.length < 4000); +}); diff --git a/tests/test_validation.py b/tests/test_validation.py index cae630c..b6077fd 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -25,6 +25,24 @@ def setUp(self): def test_valid_repository(self): self.assertEqual(self.validate(self.root), []) + def test_local_uploader_is_packaged_and_contract_changes_are_rejected(self): + plugin = self.root / "plugins/ennoia" + self.assertTrue((plugin / "mcp/file-uploader.mjs").is_file()) + for filename in ("mcp.json", ".mcp.json"): + path = plugin / filename + original = json.loads(path.read_text()) + self.assertEqual(original["mcpServers"]["ennoia-file-uploader"], { + "command": "node", "cwd": ".", "args": ["./mcp/file-uploader.mjs"], + }) + for key, value in (("command", "sh"), ("cwd", ".."), ("args", ["./other.mjs"]), ("env", {"TOKEN": "secret"})): + data = json.loads(json.dumps(original)) + data["mcpServers"]["ennoia-file-uploader"][key] = value + path.write_text(json.dumps(data)) + self.assertTrue(any("MCP" in e for e in self.validate(self.root))) + path.write_text(json.dumps(original)) + (plugin / "mcp/file-uploader.mjs").unlink() + self.assertTrue(any("MCP" in e for e in self.validate(self.root))) + def test_missing_reference_is_rejected(self): (self.root / "plugins/ennoia/skills/ennoia-knowledge/references/uploads.md").unlink() self.assertTrue(any("reference" in e for e in self.validate(self.root))) From 1fb1aab54a24ae2fcf5a577dfa5622c8fa32b458 Mon Sep 17 00:00:00 2001 From: jeonsworld Date: Wed, 16 Sep 2026 20:08:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EB=A1=9C=EC=BB=AC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=8D=94=20=EC=8B=A4=ED=96=89=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EC=99=80=20=EC=B7=A8=EC=86=8C=20=EC=B2=98=EB=A6=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/validate.yml | 2 +- CONTRIBUTING.md | 2 +- plugins/ennoia/.mcp.json | 3 +- plugins/ennoia/mcp.json | 3 +- plugins/ennoia/mcp/file-uploader.mjs | 119 ++++++++++++----- scripts/sync_manifests.py | 2 +- scripts/validate.py | 2 +- tests/test_file_uploader.mjs | 4 +- tests/test_file_uploader_lifecycle.mjs | 173 +++++++++++++++++++++++++ tests/test_validation.py | 2 +- 10 files changed, 268 insertions(+), 44 deletions(-) create mode 100644 tests/test_file_uploader_lifecycle.mjs diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d4b6e75..32791f4 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -27,7 +27,7 @@ jobs: - name: Validate packaged paths, skills and MCP contracts run: python scripts/validate.py - name: Local file uploader regression tests - run: node --test tests/test_file_uploader.mjs + run: node --test tests/test_file_uploader*.mjs - name: Regression tests run: python -m unittest discover -s tests -v - name: Check whitespace diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f07df7..3da2aa0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ python3 scripts/sync_manifests.py python3 scripts/sync_manifests.py --check python3 scripts/validate.py python3 -m unittest discover -s tests -v -node --test tests/test_file_uploader.mjs +node --test tests/test_file_uploader*.mjs python3 scripts/validate_results.py git diff --check ``` diff --git a/plugins/ennoia/.mcp.json b/plugins/ennoia/.mcp.json index ad0c82d..8ed87df 100644 --- a/plugins/ennoia/.mcp.json +++ b/plugins/ennoia/.mcp.json @@ -8,7 +8,8 @@ "command": "node", "cwd": ".", "args": [ - "./mcp/file-uploader.mjs" + "-e", + "import(require('node:url').pathToFileURL(require('node:path').join(process.env.CLAUDE_PLUGIN_ROOT || process.cwd(), 'mcp/file-uploader.mjs')).href).then(m => m.serve()).catch(() => { process.exitCode = 1; })" ] } } diff --git a/plugins/ennoia/mcp.json b/plugins/ennoia/mcp.json index 813b418..723dd72 100644 --- a/plugins/ennoia/mcp.json +++ b/plugins/ennoia/mcp.json @@ -9,7 +9,8 @@ "command": "node", "cwd": ".", "args": [ - "./mcp/file-uploader.mjs" + "-e", + "import(require('node:url').pathToFileURL(require('node:path').join(process.env.CLAUDE_PLUGIN_ROOT || process.cwd(), 'mcp/file-uploader.mjs')).href).then(m => m.serve()).catch(() => { process.exitCode = 1; })" ] } } diff --git a/plugins/ennoia/mcp/file-uploader.mjs b/plugins/ennoia/mcp/file-uploader.mjs index 513fbf7..9b24135 100644 --- a/plugins/ennoia/mcp/file-uploader.mjs +++ b/plugins/ennoia/mcp/file-uploader.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // 로컬 파일 byte는 JSON-RPC를 거치지 않고 HTTPS PUT stream으로 전송한다. import { constants } from 'node:fs'; -import { lstat, open } from 'node:fs/promises'; +import { lstat, open, realpath } from 'node:fs/promises'; import { extname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { request as httpsRequest } from 'node:https'; @@ -83,29 +83,37 @@ async function readResponse(response) { return result; } -export async function uploadFile(args, request = httpsRequest) { +export async function uploadFile(args, request = httpsRequest, { signal, openFile = open } = {}) { const { url, length } = validateArguments(args); + const checkCancellation = () => { if (signal?.aborted) fail('UPLOAD_CANCELLED'); }; let file; try { + checkCancellation(); const before = await lstat(args.local_path); if (!before.isFile() || before.isSymbolicLink()) fail('FILE_INVALID'); + checkCancellation(); // NOFOLLOW와 fd 검증으로 lstat/open 사이의 symlink 교체도 거부한다. - file = await open(args.local_path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + file = await openFile(args.local_path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); const stat = await file.stat(); + checkCancellation(); if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) fail('FILE_INVALID'); if (stat.size < 1 || stat.size > MAX_FILE_SIZE) fail('FILE_SIZE_INVALID'); if (length !== String(stat.size)) fail('CONTENT_LENGTH_MISMATCH'); - let req, source, timer; + let req, source, incoming, timer, transfer, rejectResponse; + const abortTransfer = code => { + rejectResponse?.(new UploadError(code)); + source?.destroy(); req?.destroy(); incoming?.destroy(); + }; + const cancel = () => abortTransfer('UPLOAD_CANCELLED'); try { - const response = new Promise((resolveResponse, rejectResponse) => { - req = request(url, { method: 'PUT', headers: args.headers }, incoming => { - readResponse(incoming).then(resolveResponse, rejectResponse); + const response = new Promise((resolveResponse, reject) => { + rejectResponse = reject; + req = request(url, { method: 'PUT', headers: args.headers }, received => { + incoming = received; + readResponse(incoming).then(resolveResponse, reject); }); - req.once('error', () => rejectResponse(new UploadError('UPLOAD_NETWORK_ERROR'))); - timer = setTimeout(() => { - rejectResponse(new UploadError('UPLOAD_TIMEOUT')); - req.destroy(); - }, UPLOAD_TIMEOUT_MS); + req.once('error', () => reject(new UploadError('UPLOAD_NETWORK_ERROR'))); + timer = setTimeout(() => abortTransfer('UPLOAD_TIMEOUT'), UPLOAD_TIMEOUT_MS); }); let sent = 0; const counter = new Transform({ @@ -113,18 +121,27 @@ export async function uploadFile(args, request = httpsRequest) { flush(callback) { callback(sent === stat.size ? null : new UploadError('FILE_SIZE_CHANGED')); }, }); source = file.createReadStream({ autoClose: false, start: 0, end: stat.size - 1 }); - const [result] = await Promise.all([response, pipeline(source, counter, req)]); + transfer = pipeline(source, counter, req); + const completed = Promise.all([response, transfer]); + signal?.addEventListener('abort', cancel, { once: true }); + if (signal?.aborted) cancel(); + const [result] = await completed; + checkCancellation(); if ((await file.stat()).size !== stat.size) fail('FILE_SIZE_CHANGED'); return result; } catch (error) { + checkCancellation(); if (error instanceof UploadError) throw error; fail('UPLOAD_NETWORK_ERROR'); } finally { + signal?.removeEventListener('abort', cancel); clearTimeout(timer); - source?.destroy(); - req?.destroy(); + source?.destroy(); req?.destroy(); incoming?.destroy(); + // fd close 전에 진행 중인 read와 pipeline 정리를 기다린다. + await transfer?.catch(() => {}); } } catch (error) { + checkCancellation(); if (error instanceof UploadError) throw error; fail('FILE_INVALID'); } finally { @@ -132,7 +149,7 @@ export async function uploadFile(args, request = httpsRequest) { } } -async function handleRpc(message) { +async function handleRpc(message, upload = uploadFile) { const id = message?.id ?? null; const error = (code, text) => ({ jsonrpc: '2.0', id, error: { code, message: text } }); if (!message || Array.isArray(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') return error(-32600, 'Invalid Request'); @@ -147,37 +164,69 @@ async function handleRpc(message) { if (message.method !== 'tools/call') return error(-32601, 'Method not found'); if (message.params?.name !== uploadTool.name) return error(-32602, 'Unknown tool'); try { - const result = await uploadFile(message.params.arguments); + const result = await upload(message.params.arguments); return reply({ content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result }); } catch (err) { return reply({ isError: true, content: [{ type: 'text', text: err instanceof UploadError ? err.message : 'UPLOAD_FAILED' }] }); } } -export async function serve(input = process.stdin, output = process.stdout) { - let pending = '', oversized = false; - const write = value => { if (value) output.write(JSON.stringify(value) + '\n'); }; +export async function serve(input = process.stdin, output = process.stdout, { request = httpsRequest, openFile = open } = {}) { + let pending = '', oversized = false, closed = false; + // 업로드는 하나만 허용한다. Parser와 ping은 전송 완료를 기다리지 않는다. + const active = new Map(); + const write = value => { if (value && !closed) output.write(JSON.stringify(value) + '\n'); }; + const shutdown = () => { + closed = true; + for (const job of active.values()) job.controller.abort(); + }; + const dispatch = message => { + if (message?.jsonrpc === '2.0' && message.method === 'notifications/cancelled' && !Object.hasOwn(message, 'id')) { + active.get(message.params?.requestId)?.controller.abort(); + return; + } + const isUpload = message?.jsonrpc === '2.0' && message.method === 'tools/call' + && message.params?.name === uploadTool.name && Object.hasOwn(message, 'id'); + if (!isUpload) { void handleRpc(message).then(write); return; } + if (active.size) { + write({ jsonrpc: '2.0', id: message.id, result: { isError: true, content: [{ type: 'text', text: 'UPLOAD_BUSY' }] } }); + return; + } + const controller = new AbortController(); + const job = { controller, promise: undefined }; + active.set(message.id, job); + job.promise = handleRpc(message, args => uploadFile(args, request, { signal: controller.signal, openFile })) + .then(value => { if (!controller.signal.aborted) write(value); }) + .finally(() => active.delete(message.id)); + }; input.setEncoding('utf8'); - for await (const chunk of input) { - for (const [index, part] of chunk.split('\n').entries()) { - if (index > 0) { - if (oversized) write({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Request too large' } }); - else if (pending.trim()) { - let message; - try { message = JSON.parse(pending); } - catch { write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); } - if (message !== undefined) write(await handleRpc(message)); + input.on('end', shutdown); input.on('close', shutdown); input.on('error', shutdown); + try { + for await (const chunk of input) { + for (const [index, part] of chunk.split('\n').entries()) { + if (index > 0) { + if (oversized) write({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Request too large' } }); + else if (pending.trim()) { + let message; + try { message = JSON.parse(pending); } + catch { write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); } + if (message !== undefined) dispatch(message); + } + pending = ''; oversized = false; + } + if (!oversized) { + pending += part; + if (Buffer.byteLength(pending) > MAX_RPC_SIZE) { pending = ''; oversized = true; } } - pending = ''; oversized = false; - } - if (!oversized) { - pending += part; - if (Buffer.byteLength(pending) > MAX_RPC_SIZE) { pending = ''; oversized = true; } } } + } finally { + shutdown(); + await Promise.allSettled([...active.values()].map(job => job.promise)); + input.off('end', shutdown); input.off('close', shutdown); input.off('error', shutdown); } } -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { +if (process.argv[1] && import.meta.url === pathToFileURL(await realpath(resolve(process.argv[1]))).href) { serve().catch(() => { process.exitCode = 1; }); } diff --git a/scripts/sync_manifests.py b/scripts/sync_manifests.py index 52fe423..10b2144 100644 --- a/scripts/sync_manifests.py +++ b/scripts/sync_manifests.py @@ -15,7 +15,7 @@ def outputs(root: Path) -> dict[Path, dict]: identity = {k: v for k, v in manifest.items() if k not in {"$schema", "extensions"}} expected_servers = { "ennoia": {"type": "streamable-http", "url": "https://mcp.ennoia.so/mcp"}, - "ennoia-file-uploader": {"command": "node", "cwd": ".", "args": ["./mcp/file-uploader.mjs"]}, + "ennoia-file-uploader": {"command": "node", "cwd": ".", "args": ["-e", "import(require('node:url').pathToFileURL(require('node:path').join(process.env.CLAUDE_PLUGIN_ROOT || process.cwd(), 'mcp/file-uploader.mjs')).href).then(m => m.serve()).catch(() => { process.exitCode = 1; })"]}, } if mcp.get("mcpServers") != expected_servers: raise ValueError("Ennoia 배포는 지정한 remote MCP와 local file uploader만 지원합니다.") diff --git a/scripts/validate.py b/scripts/validate.py index 7ac5d26..e04c107 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -59,7 +59,7 @@ def validate_repository(root: Path) -> list[str]: mcp = read_json(plugin / filename) expected_servers = { "ennoia": {"type": transport, "url": "https://mcp.ennoia.so/mcp"}, - "ennoia-file-uploader": {"command": "node", "cwd": ".", "args": ["./mcp/file-uploader.mjs"]}, + "ennoia-file-uploader": {"command": "node", "cwd": ".", "args": ["-e", "import(require('node:url').pathToFileURL(require('node:path').join(process.env.CLAUDE_PLUGIN_ROOT || process.cwd(), 'mcp/file-uploader.mjs')).href).then(m => m.serve()).catch(() => { process.exitCode = 1; })"]}, } if mcp.get("mcpServers") != expected_servers: errors.append(f"MCP 설정 오류 또는 credential 포함: {filename}") diff --git a/tests/test_file_uploader.mjs b/tests/test_file_uploader.mjs index 7467408..6f9fc8d 100644 --- a/tests/test_file_uploader.mjs +++ b/tests/test_file_uploader.mjs @@ -66,8 +66,8 @@ test('stdio initializes, ignores notification, lists exact tool and returns safe assert.equal(tool.name, 'upload_ennoia_rag_file'); assert.deepEqual(tool.inputSchema.required, ['local_path', 'upload_url', 'headers']); assert.deepEqual(Object.keys(tool.inputSchema.properties), ['local_path', 'upload_url', 'headers']); - assert.equal(replies[2].result.isError, true); - assert.equal(replies[3].error.code, -32602); + assert.equal(replies.find(reply => reply.id === 3).result.isError, true); + assert.equal(replies.find(reply => reply.id === 4).error.code, -32602); }); test('streams actual disk chunks by PUT with exact headers and returns safe file identity', async t => { diff --git a/tests/test_file_uploader_lifecycle.mjs b/tests/test_file_uploader_lifecycle.mjs new file mode 100644 index 0000000..990de2b --- /dev/null +++ b/tests/test_file_uploader_lifecycle.mjs @@ -0,0 +1,173 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm, symlink, mkdir, readFile, open } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PassThrough, Writable, Readable } from 'node:stream'; +import { spawn } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; +import { serve, uploadFile } from '../plugins/ennoia/mcp/file-uploader.mjs'; + +const plugin = fileURLToPath(new URL('../plugins/ennoia/', import.meta.url)); +const script = join(plugin, 'mcp/file-uploader.mjs'); +const list = { jsonrpc: '2.0', id: 1, method: 'tools/list' }; +async function temp(t) { + const dir = await mkdtemp(join(tmpdir(), 'ennoia-lifecycle-')); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} +async function launch(args, options = {}) { + const child = spawn(process.execPath, args, { stdio: ['pipe', 'pipe', 'pipe'], ...options }); + let stdout = '', stderr = ''; + child.stdout.on('data', chunk => stdout += chunk); + child.stderr.on('data', chunk => stderr += chunk); + child.stdin.end(JSON.stringify(list) + '\n'); + const code = await new Promise(resolve => child.on('close', resolve)); + return { code, stdout, stderr }; +} + +for (const kind of ['file', 'parent']) test(`symlink ${kind} entry starts stdio server`, async t => { + const dir = await temp(t); + const link = join(dir, 'linked'); + await symlink(kind === 'file' ? script : plugin, link); + const result = await launch([kind === 'file' ? link : join(link, 'mcp/file-uploader.mjs')]); + assert.equal(result.code, 0); + assert.equal(result.stderr, ''); + assert.equal(JSON.parse(result.stdout).result.tools[0].name, 'upload_ennoia_rag_file'); +}); + +for (const host of ['claude', 'codex']) test(`generated launch resolves ${host} plugin root`, async t => { + const dir = await temp(t); + await mkdir(join(dir, 'mcp')); + await writeFile(join(dir, 'mcp/file-uploader.mjs'), 'process.stdout.write("WRONG_PROJECT_SCRIPT");'); + const config = JSON.parse(await readFile(join(plugin, '.mcp.json'))).mcpServers['ennoia-file-uploader']; + assert.equal(config.command, 'node'); assert.equal(config.cwd, '.'); + const env = { ...process.env }; + delete env.CLAUDE_PLUGIN_ROOT; + if (host === 'claude') env.CLAUDE_PLUGIN_ROOT = plugin; + const result = await launch(config.args, { cwd: host === 'claude' ? dir : plugin, env }); + assert.equal(result.code, 0); + assert.equal(result.stderr, ''); + assert.equal(JSON.parse(result.stdout).result.tools[0].name, 'upload_ennoia_rag_file'); +}); + +async function fixture(t) { + const dir = await temp(t); + const local_path = join(dir, 'large.pdf'); + const bytes = Buffer.alloc(4 * 1024 * 1024, 37); + await writeFile(local_path, bytes); + return { local_path, upload_url: 'https://mcp.ennoia.so/rag/uploads/test-id', headers: { + 'X-Ennoia-Upload-Token': 'test-token', 'Content-Type': 'application/pdf', 'Content-Length': String(bytes.length), + } }; +} +function slowNetwork() { + let release, req, bytes = 0, calls = 0; + let start; + const started = new Promise(resolve => start = resolve); + const request = (url, options, onResponse) => { + calls++; + req = new Writable({ + autoDestroy: false, highWaterMark: 1, + write(chunk, encoding, callback) { + bytes += chunk.length; + if (!release) { release = callback; start(); } else callback(); + }, + final(callback) { + callback(); + const response = Readable.from(['{}']); response.statusCode = 200; onResponse(response); + }, + }); + return req; + }; + return { request, started, release: () => release?.(), stop: () => req?.destroy(new Error('test cleanup')), get req() { return req; }, get bytes() { return bytes; }, get calls() { return calls; } }; +} +function observedFiles() { + const files = [], sources = []; + let bytesRead = 0; + const openFile = async (...args) => { + const file = await open(...args); files.push(file); + const read = file.read.bind(file); + file.read = async (...readArgs) => { const result = await read(...readArgs); bytesRead += result.bytesRead; return result; }; + const readFile = file.readFile.bind(file); + file.readFile = async (...readArgs) => { const result = await readFile(...readArgs); bytesRead += result.length; return result; }; + const create = file.createReadStream.bind(file); + file.createReadStream = options => { const source = create(options); sources.push(source); return source; }; + return file; + }; + return { openFile, files, sources, get bytesRead() { return bytesRead; } }; +} +async function until(condition, message) { + for (let i = 0; i < 50 && !condition(); i++) await delay(5); + assert.ok(condition(), message); +} +function client(t, network, observed) { + const input = new PassThrough(); const replies = []; + const output = new Writable({ write(chunk, encoding, callback) { replies.push(JSON.parse(chunk.toString())); callback(); } }); + const serving = serve(input, output, { request: network.request, openFile: observed.openFile }); + t.after(async () => { input.end(); network.stop(); await serving; }); + const send = message => input.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n'); + return { input, replies, serving, send }; +} + +test('ping precedes slow upload completion and excess uploads are rejected without work', async t => { + const network = slowNetwork(), observed = observedFiles(), c = client(t, network, observed); + const args = await fixture(t); + c.send({ id: 1, method: 'tools/call', params: { name: 'upload_ennoia_rag_file', arguments: args } }); + await until(() => network.bytes > 0, 'upload must start before ping'); + c.send({ id: 2, method: 'ping' }); + c.send({ id: 3, method: 'tools/call', params: { name: 'upload_ennoia_rag_file', arguments: args } }); + await until(() => c.replies.some(x => x.id === 2), 'ping must respond before network release'); + assert.ok(!c.replies.some(x => x.id === 1)); + await until(() => c.replies.some(x => x.id === 3), 'busy response must be immediate'); + assert.equal(c.replies.find(x => x.id === 3).result.content[0].text, 'UPLOAD_BUSY'); + assert.equal(network.calls, 1); + network.release(); + await until(() => c.replies.some(x => x.id === 1), 'upload completes after release'); + assert.equal(c.replies.find(x => x.id === 1).result.structuredContent.uploaded, true); +}); + +test('request cancellation stops bytes and closes request, source, and fd', async t => { + const network = slowNetwork(), observed = observedFiles(), c = client(t, network, observed); + c.send({ id: 'upload', method: 'tools/call', params: { name: 'upload_ennoia_rag_file', arguments: await fixture(t) } }); + await until(() => network.bytes > 0, 'upload must start'); + c.send({ method: 'notifications/cancelled', params: { requestId: 'unrelated' } }); + assert.equal(network.req.destroyed, false); + c.send({ method: 'notifications/cancelled', params: { requestId: 'upload' } }); + await until(() => network.req.destroyed && observed.files.every(x => x.fd === -1), 'cancel must close all resources'); + assert.ok(observed.sources.every(x => x.destroyed)); + const sent = network.bytes; network.release(); await delay(20); + assert.equal(network.bytes, sent); + assert.ok(!c.replies.some(x => x.id === 'upload'), 'cancelled request has no response'); + c.send({ id: 4, method: 'ping' }); + await until(() => c.replies.some(x => x.id === 4), 'server remains responsive'); +}); + +test('stdin close cancels active upload and waits for fd cleanup', async t => { + const network = slowNetwork(), observed = observedFiles(), c = client(t, network, observed); + c.send({ id: 1, method: 'tools/call', params: { name: 'upload_ennoia_rag_file', arguments: await fixture(t) } }); + await until(() => network.bytes > 0, 'upload must start'); + c.input.end(); + await Promise.race([c.serving, delay(300).then(() => { throw new Error('stdin cleanup timed out'); })]); + assert.ok(network.req.destroyed); + assert.ok(observed.sources.every(x => x.destroyed)); + assert.ok(observed.files.every(x => x.fd === -1)); +}); + +test('disk reads obey network backpressure before release', async t => { + const network = slowNetwork(), observed = observedFiles(); + const args = await fixture(t); + const upload = uploadFile(args, network.request, { openFile: observed.openFile }); + t.after(async () => { network.stop(); await upload.catch(() => {}); }); + await network.started; + await delay(30); + assert.ok(observed.bytesRead > 0, 'observe actual fd reads'); + assert.ok(observed.bytesRead < Number(args.headers['Content-Length']) / 4, 'blocked network must bound disk read-ahead'); + const bounded = observed.bytesRead; + await delay(30); + assert.equal(observed.bytesRead, bounded, 'disk stops reading while network stays blocked'); + network.release(); + await upload; + assert.equal(observed.bytesRead, Number(args.headers['Content-Length'])); + assert.ok(observed.files.every(x => x.fd === -1)); +}); diff --git a/tests/test_validation.py b/tests/test_validation.py index b6077fd..2189049 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -32,7 +32,7 @@ def test_local_uploader_is_packaged_and_contract_changes_are_rejected(self): path = plugin / filename original = json.loads(path.read_text()) self.assertEqual(original["mcpServers"]["ennoia-file-uploader"], { - "command": "node", "cwd": ".", "args": ["./mcp/file-uploader.mjs"], + "command": "node", "cwd": ".", "args": ["-e", "import(require('node:url').pathToFileURL(require('node:path').join(process.env.CLAUDE_PLUGIN_ROOT || process.cwd(), 'mcp/file-uploader.mjs')).href).then(m => m.serve()).catch(() => { process.exitCode = 1; })"], }) for key, value in (("command", "sh"), ("cwd", ".."), ("args", ["./other.mjs"]), ("env", {"TOKEN": "secret"})): data = json.loads(json.dumps(original))