Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "ennoia",
"source": "./plugins/ennoia",
"description": "Ennoia에서 에이전트 생성, 문서 지식 연결, App 실행, 운영 진단과 제품 피드백을 수행합니다.",
"version": "1.4.0"
"version": "1.4.1"
}
]
}
2 changes: 1 addition & 1 deletion plugins/ennoia/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ennoia",
"version": "1.4.0",
"version": "1.4.1",
"description": "Ennoia에서 에이전트 생성, 문서 지식 연결, App 실행, 운영 진단과 제품 피드백을 수행합니다.",
"author": {
"name": "Wantedlab",
Expand Down
2 changes: 1 addition & 1 deletion plugins/ennoia/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ennoia",
"version": "1.4.0",
"version": "1.4.1",
"description": "Ennoia에서 에이전트 생성, 문서 지식 연결, App 실행, 운영 진단과 제품 피드백을 수행합니다.",
"author": {
"name": "Wantedlab",
Expand Down
63 changes: 53 additions & 10 deletions plugins/ennoia/mcp/file-uploader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,48 @@ 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 ERROR_DETAILS = Object.freeze({
ARGUMENTS_INVALID: ['업로드 인자 형식이 올바르지 않습니다.', 'local_path, upload_url, headers만 전달하세요.'],
UPLOAD_URL_INVALID: ['업로드 URL을 사용할 수 없습니다.', 'prepare_rag_document_upload로 새 upload ticket을 발급하세요.'],
FILE_TYPE_UNSUPPORTED: ['지원하지 않는 파일 형식입니다.', 'get_rag_capabilities에서 지원 확장자를 확인하세요.'],
HEADERS_INVALID: ['업로드 header가 올바르지 않습니다.', 'prepare_rag_document_upload가 반환한 headers를 수정 없이 전달하세요.'],
FILE_NOT_FOUND_ON_UPLOADER_HOST: [
'업로더가 실행되는 device host에서 파일을 찾을 수 없습니다.',
'device host에 연결된 로컬 폴더의 경로로 다시 시도하거나 Ennoia 웹 업로드를 사용하세요.',
],
FILE_ACCESS_DENIED: [
'업로더가 실행되는 device host에서 파일을 읽을 권한이 없습니다.',
'device host에서 읽을 수 있는 위치로 파일을 옮기거나 Ennoia 웹 업로드를 사용하세요.',
],
FILE_NOT_REGULAR: [
'업로드 대상은 symlink나 디렉터리가 아닌 일반 파일이어야 합니다.',
'device host에서 읽을 수 있는 일반 파일을 선택하거나 Ennoia 웹 업로드를 사용하세요.',
],
});
class UploadError extends Error {
constructor(code) { super(code); this.code = code; }
}
const fail = code => { throw new UploadError(code); };

function errorDetail(error) {
const code = error instanceof UploadError ? error.code : 'UPLOAD_FAILED';
const [message, next_action] = ERROR_DETAILS[code] ?? [
'파일 업로드를 완료하지 못했습니다.',
'업로드 정보를 다시 확인하고 계속 실패하면 Ennoia 웹 업로드를 사용하세요.',
];
return { code, message, next_action };
}

function fileSystemError(error) {
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return 'FILE_NOT_FOUND_ON_UPLOADER_HOST';
if (error?.code === 'EACCES' || error?.code === 'EPERM') return 'FILE_ACCESS_DENIED';
if (error?.code === 'ELOOP') return 'FILE_NOT_REGULAR';
return 'FILE_INVALID';
}

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로 처리 상태를 확인하세요.',
description: 'prepare_rag_document_upload가 반환한 upload_url과 exact headers로 로컬 파일을 PUT합니다. 이 도구가 실행되는 device host에서 보이는 local_path만 읽을 수 있으며, 채팅 첨부의 cloud path는 device host에서 보이지 않을 수 있습니다. 경로를 읽을 수 없으면 연결된 로컬 폴더의 파일을 사용하거나 Ennoia 웹 업로드 링크를 안내하세요. 파일 byte/base64는 MCP JSON에 넣지 않습니다. 성공 후 collection_code/file_seq로 처리 상태를 확인하세요.',
inputSchema: {
type: 'object', additionalProperties: false,
properties: {
Expand Down Expand Up @@ -89,14 +125,20 @@ export async function uploadFile(args, request = httpsRequest, { signal, openFil
let file;
try {
checkCancellation();
const before = await lstat(args.local_path);
if (!before.isFile() || before.isSymbolicLink()) fail('FILE_INVALID');
let before;
try { before = await lstat(args.local_path); }
catch (error) { fail(fileSystemError(error)); }
if (!before.isFile() || before.isSymbolicLink()) fail('FILE_NOT_REGULAR');
checkCancellation();
// NOFOLLOW와 fd 검증으로 lstat/open 사이의 symlink 교체도 거부한다.
file = await openFile(args.local_path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const stat = await file.stat();
try { file = await openFile(args.local_path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (error) { fail(fileSystemError(error)); }
let stat;
try { stat = await file.stat(); }
catch (error) { fail(fileSystemError(error)); }
checkCancellation();
if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) fail('FILE_INVALID');
if (!stat.isFile()) fail('FILE_NOT_REGULAR');
if (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, incoming, timer, transfer, rejectResponse;
Expand Down Expand Up @@ -143,7 +185,7 @@ export async function uploadFile(args, request = httpsRequest, { signal, openFil
} catch (error) {
checkCancellation();
if (error instanceof UploadError) throw error;
fail('FILE_INVALID');
fail(fileSystemError(error));
} finally {
await file?.close().catch(() => {});
}
Expand All @@ -157,7 +199,7 @@ async function handleRpc(message, upload = uploadFile) {
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' },
capabilities: { tools: {} }, serverInfo: { name: 'ennoia-file-uploader', version: '1.4.1' },
});
if (message.method === 'ping') return reply({});
if (message.method === 'tools/list') return reply({ tools: [uploadTool] });
Expand All @@ -167,7 +209,8 @@ async function handleRpc(message, upload = uploadFile) {
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' }] });
const detail = errorDetail(err);
return reply({ isError: true, content: [{ type: 'text', text: JSON.stringify(detail) }], structuredContent: detail });
}
}

Expand Down
2 changes: 1 addition & 1 deletion plugins/ennoia/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "ennoia",
"version": "1.4.0",
"version": "1.4.1",
"description": "Ennoia에서 에이전트 생성, 문서 지식 연결, App 실행, 운영 진단과 제품 피드백을 수행합니다.",
"author": {
"name": "Wantedlab",
Expand Down
5 changes: 3 additions & 2 deletions plugins/ennoia/skills/ennoia-knowledge/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ Ennoia MCP의 현재 input schema를 확인하고 `get_current_ennoia_project`

1. 대상 프로젝트와 정확한 `collection_code`를 이미 알면 그 code로 `get_rag_collection`을 우선 조회한다. 표시 이름이나 목록을 다시 찾지 않는다. code를 모를 때만 `list_multi_agent_rag_collections`로 query 검색하고 필요한 단건을 읽는다. 적합한 기존 컬렉션을 사용한다. 신규가 요청됐거나 필요할 때 `create_rag_collection`을 사용한다.
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`는 이 계약의 인자가 아니다. 동일한 프로젝트에서 등록과 상태 확인을 이어간다.
3. 로컬 파일은 [업로드 경로](references/uploads.md)를 읽는다. `get_current_ennoia_project`의 검증된 `group_code`·`project_code`로 `project_scope={scope:"specified", group_code, project_code}`를 확정하고 이후 모든 원격 호출에 동일하게 사용한다. ASCII 파일명·실제 byte 크기·content type을 확인해 `prepare_rag_document_upload(collection_code, file_name, size_bytes, content_type, project_scope)`를 호출한다. 한글 등 non-ASCII 파일명은 영문·숫자·`-`·`_`로 바꾸고 확장자를 유지한 실제 로컬 파일 경로를 사용하도록 안내한다.
4. prepare 성공 응답이 `method=PUT`인지 확인한 뒤에만 `upload_url`과 `headers`를 그대로 `upload_ennoia_rag_file(local_path, upload_url, headers)`에 전달한다. 다른 method이면 uploader를 호출하지 않는다. 로컬 tool은 자신이 실행되는 device host의 파일을 디스크에서 읽어 PUT한다. 채팅 첨부의 cloud path처럼 device host에서 볼 수 없는 경로를 전달하지 않는다. 파일 byte·base64·전체 본문을 모델 context 또는 MCP JSON에 넣지 않는다.
5. 원격 tool에는 `collection_code`, 파일 조회에는 `file_seq`를 사용한다. `collection_id`·`file_id`·`file_data`는 이 계약의 인자가 아니다. 등록, 목록, 상태 확인에 같은 `project_scope`를 전달한다.

## 처리 완료와 연결

Expand Down
18 changes: 11 additions & 7 deletions plugins/ennoia/skills/ennoia-knowledge/references/uploads.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@
## 로컬 파일 전송 계약

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이면 먼저 파일 목록·상태를 확인하고 중복 업로드를 피한다.
2. 파일 전송은 `upload_ennoia_rag_file`을 사용하며 일반 HTTP client나 `curl`을 대체 수단으로 사용하지 않는다.
3. `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는 지원하지 않는다.
4. `file_name`은 ASCII만 사용한다. 한글 등 non-ASCII 이름이면 영문·숫자·`-`·`_`로 이름을 바꾸고 기존 확장자를 유지한 실제 파일을 준비하도록 안내한다. 표시 이름만 바꾸거나 존재하지 않는 경로를 만들어내지 않는다.
5. 현재 원격 schema에 맞춰 `prepare_rag_document_upload`에 `collection_code`, `file_name`, `size_bytes`, `content_type` 및 동일한 `project_scope`를 전달한다. 로컬 경로를 원격 prepare에 전달하지 않는다.
6. 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에 넣지 않는다.
7. 로컬 tool은 자신이 실행되는 device host에서 보이는 파일만 읽는다. Cowork 채팅 첨부의 cloud path와 device host의 연결된 로컬 폴더는 서로 다른 filesystem일 수 있다. `FILE_NOT_FOUND_ON_UPLOADER_HOST`, `FILE_ACCESS_DENIED`, `FILE_NOT_REGULAR`이면 같은 ticket을 임의의 다른 경로로 반복하지 않고 아래 웹 업로드 링크를 안내한다.
8. 웹 링크는 확인된 `group_code`, `project_code`, `collection_code`를 각각 percent-encoding하여 만든다. 표시 이름이나 예시 ID를 대신 넣지 않는다. 환경은 현재 Ennoia MCP 연결 endpoint로 판단한다. `https://mcp.ennoia.so/mcp`이면 `[Ennoia에서 파일 업로드](https://ennoia.so/studio/rag/files-detail?group={group_code}&project={project_code}&slug=rag&collection={collection_code})`, `https://dev-mcp-server.ennoia.so/mcp`이면 `[Ennoia dev에서 파일 업로드](https://dev.ennoia.so/studio/rag/files-detail?group={group_code}&project={project_code}&slug=rag&collection={collection_code})` 형식을 사용한다. 결과에는 `{...}` placeholder가 아니라 실제 값을 넣은 클릭 가능한 Markdown 링크를 제공한다. 현재 context에 code가 없으면 `get_current_ennoia_project`로 확인한다. endpoint를 확인할 수 없으면 환경을 추측하거나 잘못된 링크를 만들지 않는다.
9. 로컬 tool은 disk stream으로 1회 PUT하고 redirect를 거부한다. 대상은 HTTPS `mcp.ennoia.so` 또는 `dev-mcp-server.ennoia.so`의 `/rag/uploads/{opaque-id}`만 허용한다. upload URL·token·headers를 로그·보고서·공유 링크로 출력하지 않는다.
10. 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`를 유지한다.
11. `processing_state=ready`와 `ready_for_agent=true`를 함께 확인해야 준비 완료다. 업로드 ticket 발급, HTTP 전송 성공, 인덱싱 완료를 구분한다. timeout이면 먼저 파일 목록·상태를 확인하고 중복 업로드를 피한다.

로컬 uploader가 설치되지 않았거나 host가 Node 실행·파일 접근을 지원하지 않으면 Ennoia 파일 업로드 화면을 안내하고, 완료 후 같은 컬렉션에서 상태 확인을 이어간다. 파일을 base64로 바꾸거나 원격 서버에서 로컬 경로를 읽으려 하지 않는다.
device host가 Node 실행·파일 접근을 지원하지 않으면 위 규칙으로 실제 Ennoia 파일 업로드 화면 링크를 안내하고, 완료 후 같은 컬렉션에서 상태 확인을 이어간다. 파일을 base64로 바꾸거나 원격 서버에서 로컬 경로를 읽으려 하지 않는다. binary 파일을 임의로 텍스트 변환해 `upload_rag_text_document`로 대신 올리지 않는다.

URL import에 cookie·Authorization header·signed query를 넣거나 내부/private 주소를 제공하지 않는다. 보호된 문서를 public으로 공개해서 이 경로에 맞추지 않는다. 사용자가 제공한 로컬 파일은 위 local upload 경로를 사용한다.

Expand Down
Loading
Loading