note-connector 0.2.11 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/py/pyproject.toml +1 -1
- package/py/src/note_mcp/api/images.py +165 -0
- package/py/src/note_mcp/server.py +58 -0
package/package.json
CHANGED
package/py/pyproject.toml
CHANGED
|
@@ -733,3 +733,168 @@ async def upload_eyecatch_base64(
|
|
|
733
733
|
if tmp_path is not None:
|
|
734
734
|
with contextlib.suppress(OSError):
|
|
735
735
|
os.unlink(tmp_path)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
# =============================================================================
|
|
739
|
+
# Chunked Base64 Image Upload
|
|
740
|
+
# =============================================================================
|
|
741
|
+
|
|
742
|
+
# Max chunk size for safe MCP transport (64KB base64)
|
|
743
|
+
MAX_CHUNK_SIZE: int = 64 * 1024
|
|
744
|
+
|
|
745
|
+
# In-memory chunk buffer: upload_id -> chunk state
|
|
746
|
+
# Each entry: {"chunks": {index: bytes}, "total_chunks": int, "mime_type": str, "note_id": str}
|
|
747
|
+
_chunk_buffer: dict[str, dict[str, Any]] = {}
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _reset_chunk_state(upload_id: str) -> None:
|
|
751
|
+
"""Clear chunk buffer state for an upload_id."""
|
|
752
|
+
_chunk_buffer.pop(upload_id, None)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _get_chunk_state(upload_id: str) -> dict[str, Any] | None:
|
|
756
|
+
"""Get chunk buffer state for an upload_id."""
|
|
757
|
+
return _chunk_buffer.get(upload_id)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
async def upload_eyecatch_chunked(
|
|
761
|
+
session: Session,
|
|
762
|
+
upload_id: str,
|
|
763
|
+
note_id: str,
|
|
764
|
+
mime_type: str,
|
|
765
|
+
chunk: str,
|
|
766
|
+
chunk_index: int,
|
|
767
|
+
total_chunks: int,
|
|
768
|
+
) -> Image | None:
|
|
769
|
+
"""Upload an eyecatch image in base64 chunks.
|
|
770
|
+
|
|
771
|
+
Receives base64-encoded chunks of an image, buffers them, and on the
|
|
772
|
+
final chunk assembles the complete image, validates it, and uploads
|
|
773
|
+
to note.com as an eyecatch.
|
|
774
|
+
|
|
775
|
+
This is designed for ChatGPT where large base64 strings may be
|
|
776
|
+
truncated during MCP tool argument transmission.
|
|
777
|
+
|
|
778
|
+
Args:
|
|
779
|
+
session: Authenticated session
|
|
780
|
+
upload_id: Unique ID for this upload session (generated by ChatGPT)
|
|
781
|
+
note_id: The note ID to associate the image with
|
|
782
|
+
mime_type: MIME type of the image
|
|
783
|
+
chunk: Base64-encoded chunk data
|
|
784
|
+
chunk_index: 0-based index of this chunk
|
|
785
|
+
total_chunks: Total number of chunks expected
|
|
786
|
+
|
|
787
|
+
Returns:
|
|
788
|
+
Image object on final chunk success, None for intermediate chunks
|
|
789
|
+
|
|
790
|
+
Raises:
|
|
791
|
+
NoteAPIError: If validation fails or API request fails
|
|
792
|
+
"""
|
|
793
|
+
# Validate parameters
|
|
794
|
+
if not upload_id.strip():
|
|
795
|
+
raise NoteAPIError(
|
|
796
|
+
code=ErrorCode.INVALID_INPUT,
|
|
797
|
+
message="upload_id が空です。",
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
if total_chunks < 1:
|
|
801
|
+
raise NoteAPIError(
|
|
802
|
+
code=ErrorCode.INVALID_INPUT,
|
|
803
|
+
message=f"total_chunks は1以上である必要があります: {total_chunks}",
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
if chunk_index < 0 or chunk_index >= total_chunks:
|
|
807
|
+
raise NoteAPIError(
|
|
808
|
+
code=ErrorCode.INVALID_INPUT,
|
|
809
|
+
message=(f"chunk_index が範囲外です: {chunk_index} (有効範囲: 0〜{total_chunks - 1})"),
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
if not chunk.strip():
|
|
813
|
+
raise NoteAPIError(
|
|
814
|
+
code=ErrorCode.INVALID_BASE64,
|
|
815
|
+
message=f"chunk {chunk_index + 1}/{total_chunks} が空です。",
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# Validate MIME type (only on first chunk to avoid repeat checks)
|
|
819
|
+
if chunk_index == 0:
|
|
820
|
+
_validate_mime_type(mime_type)
|
|
821
|
+
|
|
822
|
+
# Initialize or get chunk state
|
|
823
|
+
state = _get_chunk_state(upload_id)
|
|
824
|
+
if state is None:
|
|
825
|
+
state = {
|
|
826
|
+
"chunks": {},
|
|
827
|
+
"total_chunks": total_chunks,
|
|
828
|
+
"mime_type": mime_type,
|
|
829
|
+
"note_id": note_id,
|
|
830
|
+
}
|
|
831
|
+
_chunk_buffer[upload_id] = state
|
|
832
|
+
else:
|
|
833
|
+
# Validate consistency
|
|
834
|
+
if state["total_chunks"] != total_chunks:
|
|
835
|
+
raise NoteAPIError(
|
|
836
|
+
code=ErrorCode.INVALID_INPUT,
|
|
837
|
+
message=(f"total_chunks が一致しません: (前回: {state['total_chunks']}, 今回: {total_chunks})"),
|
|
838
|
+
)
|
|
839
|
+
if state["mime_type"] != mime_type:
|
|
840
|
+
raise NoteAPIError(
|
|
841
|
+
code=ErrorCode.INVALID_INPUT,
|
|
842
|
+
message=(f"mime_type が一致しません: (前回: {state['mime_type']}, 今回: {mime_type})"),
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
# Store raw base64 chunk (decode happens after assembly to avoid
|
|
846
|
+
# base64 alignment issues when splitting across 3-byte boundaries)
|
|
847
|
+
state["chunks"][chunk_index] = chunk.strip()
|
|
848
|
+
|
|
849
|
+
received = len(state["chunks"])
|
|
850
|
+
logger.debug(
|
|
851
|
+
"Chunk %d/%d for upload_id=%s, received=%d/%d",
|
|
852
|
+
chunk_index + 1,
|
|
853
|
+
total_chunks,
|
|
854
|
+
upload_id,
|
|
855
|
+
received,
|
|
856
|
+
total_chunks,
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
# Return early if not all chunks received
|
|
860
|
+
if received < total_chunks:
|
|
861
|
+
return None
|
|
862
|
+
|
|
863
|
+
# All chunks received - assemble base64 and decode
|
|
864
|
+
try:
|
|
865
|
+
# Concatenate base64 chunks in order
|
|
866
|
+
chunks_list = state["chunks"]
|
|
867
|
+
assembled_b64 = "".join(chunks_list[i] for i in range(total_chunks))
|
|
868
|
+
|
|
869
|
+
# Decode the complete base64 string
|
|
870
|
+
image_bytes = _decode_base64_image(assembled_b64)
|
|
871
|
+
|
|
872
|
+
# Validate the assembled image
|
|
873
|
+
_validate_image_bytes(image_bytes, mime_type)
|
|
874
|
+
|
|
875
|
+
# Determine file extension
|
|
876
|
+
extension = MIME_TO_EXTENSION.get(mime_type, ".bin")
|
|
877
|
+
|
|
878
|
+
# Write to temp file and upload
|
|
879
|
+
tmp_path: str | None = None
|
|
880
|
+
try:
|
|
881
|
+
fd, tmp_path = tempfile.mkstemp(suffix=extension)
|
|
882
|
+
os.close(fd)
|
|
883
|
+
|
|
884
|
+
with open(tmp_path, "wb") as f:
|
|
885
|
+
f.write(image_bytes)
|
|
886
|
+
|
|
887
|
+
image = await _upload_image_internal(
|
|
888
|
+
session=session,
|
|
889
|
+
file_path=tmp_path,
|
|
890
|
+
note_id=note_id,
|
|
891
|
+
image_type=ImageType.EYECATCH,
|
|
892
|
+
)
|
|
893
|
+
return image
|
|
894
|
+
finally:
|
|
895
|
+
if tmp_path is not None:
|
|
896
|
+
with contextlib.suppress(OSError):
|
|
897
|
+
os.unlink(tmp_path)
|
|
898
|
+
finally:
|
|
899
|
+
# Always clean up state
|
|
900
|
+
_reset_chunk_state(upload_id)
|
|
@@ -26,6 +26,7 @@ from note_mcp.api.images import (
|
|
|
26
26
|
insert_image_via_api,
|
|
27
27
|
upload_body_image,
|
|
28
28
|
upload_eyecatch_base64,
|
|
29
|
+
upload_eyecatch_chunked,
|
|
29
30
|
upload_eyecatch_image,
|
|
30
31
|
)
|
|
31
32
|
from note_mcp.api.preview import get_preview_html
|
|
@@ -326,6 +327,63 @@ async def note_set_eyecatch_base64(
|
|
|
326
327
|
return "アイキャッチ画像を設定しました。"
|
|
327
328
|
|
|
328
329
|
|
|
330
|
+
@mcp.tool()
|
|
331
|
+
@require_session
|
|
332
|
+
@handle_api_error
|
|
333
|
+
async def note_set_eyecatch_base64_chunked(
|
|
334
|
+
session: Session,
|
|
335
|
+
upload_id: Annotated[str, "アップロードセッションを識別する一意なID(UUID推奨)"],
|
|
336
|
+
note_id: Annotated[str, "アイキャッチ画像を設定する記事のID(数値IDまたは記事キー n... 形式)"],
|
|
337
|
+
mime_type: Annotated[str, "画像のMIMEタイプ(image/png, image/jpeg, image/webp など)"],
|
|
338
|
+
chunk: Annotated[str, "base64エンコードされた画像データのチャンク"],
|
|
339
|
+
chunk_index: Annotated[int, "このチャンクの0ベースのインデックス"],
|
|
340
|
+
total_chunks: Annotated[int, "全チャンク数"],
|
|
341
|
+
) -> str:
|
|
342
|
+
"""base64画像を分割(チャンク)で送信し、アイキャッチ画像を設定します。
|
|
343
|
+
|
|
344
|
+
大きなbase64画像をChatGPT経由で安全に転送するためのツールです。
|
|
345
|
+
base64文字列を複数チャンクに分割して順次送信し、全チャンクが
|
|
346
|
+
揃った時点で画像を組み立ててnote.comにアップロードします。
|
|
347
|
+
|
|
348
|
+
使い方:
|
|
349
|
+
1. ChatGPT側でbase64を分割(例: 50KBずつ)
|
|
350
|
+
2. upload_id(UUID)を生成
|
|
351
|
+
3. 各チャンクを note_set_eyecatch_base64_chunked で送信
|
|
352
|
+
4. 全チャンク送信後、自動的に画像が組み立てられアップロードされる
|
|
353
|
+
|
|
354
|
+
対応形式: PNG, JPEG, WebP, GIF
|
|
355
|
+
最大合計サイズ: 10MB
|
|
356
|
+
1チャンクあたり推奨サイズ: 64KB以下
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
upload_id: アップロードセッションの一意なID
|
|
360
|
+
note_id: アイキャッチ画像を設定する記事のID
|
|
361
|
+
mime_type: 画像のMIMEタイプ
|
|
362
|
+
chunk: base64エンコードされたチャンクデータ
|
|
363
|
+
chunk_index: このチャンクのインデックス(0始まり)
|
|
364
|
+
total_chunks: 全チャンク数
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
中間チャンクでは受信状況、最終チャンクでは設定結果
|
|
368
|
+
"""
|
|
369
|
+
image = await upload_eyecatch_chunked(
|
|
370
|
+
session=session,
|
|
371
|
+
upload_id=upload_id,
|
|
372
|
+
note_id=note_id,
|
|
373
|
+
mime_type=mime_type,
|
|
374
|
+
chunk=chunk,
|
|
375
|
+
chunk_index=chunk_index,
|
|
376
|
+
total_chunks=total_chunks,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if image is None:
|
|
380
|
+
return f"チャンク {chunk_index + 1}/{total_chunks} を受信しました。 upload_id: {upload_id}"
|
|
381
|
+
|
|
382
|
+
if image.url:
|
|
383
|
+
return f"アイキャッチ画像を設定しました。URL: {image.url}"
|
|
384
|
+
return "アイキャッチ画像を設定しました。"
|
|
385
|
+
|
|
386
|
+
|
|
329
387
|
@mcp.tool()
|
|
330
388
|
@require_session
|
|
331
389
|
@handle_api_error
|