note-connector 0.2.5 → 0.2.6

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.
Files changed (46) hide show
  1. package/dist/paths.js +4 -0
  2. package/dist/setup-dependencies.js +56 -13
  3. package/package.json +3 -2
  4. package/py/pyproject.toml +86 -0
  5. package/py/src/note_mcp/__init__.py +7 -0
  6. package/py/src/note_mcp/__main__.py +65 -0
  7. package/py/src/note_mcp/api/__init__.py +31 -0
  8. package/py/src/note_mcp/api/articles.py +1395 -0
  9. package/py/src/note_mcp/api/client.py +318 -0
  10. package/py/src/note_mcp/api/embeds.py +482 -0
  11. package/py/src/note_mcp/api/images.py +456 -0
  12. package/py/src/note_mcp/api/preview.py +142 -0
  13. package/py/src/note_mcp/api/public_notes.py +150 -0
  14. package/py/src/note_mcp/auth/__init__.py +9 -0
  15. package/py/src/note_mcp/auth/browser.py +574 -0
  16. package/py/src/note_mcp/auth/file_session.py +145 -0
  17. package/py/src/note_mcp/auth/session.py +240 -0
  18. package/py/src/note_mcp/browser/__init__.py +10 -0
  19. package/py/src/note_mcp/browser/config.py +21 -0
  20. package/py/src/note_mcp/browser/manager.py +182 -0
  21. package/py/src/note_mcp/browser/preview.py +68 -0
  22. package/py/src/note_mcp/browser/url_helpers.py +18 -0
  23. package/py/src/note_mcp/chatgpt/__init__.py +1 -0
  24. package/py/src/note_mcp/chatgpt/__main__.py +63 -0
  25. package/py/src/note_mcp/chatgpt/access_log.py +25 -0
  26. package/py/src/note_mcp/chatgpt/auth.py +52 -0
  27. package/py/src/note_mcp/chatgpt/images.py +92 -0
  28. package/py/src/note_mcp/chatgpt/login_once.py +26 -0
  29. package/py/src/note_mcp/chatgpt/middleware.py +31 -0
  30. package/py/src/note_mcp/chatgpt/tools.py +255 -0
  31. package/py/src/note_mcp/chatgpt/widgets.py +121 -0
  32. package/py/src/note_mcp/decorators.py +113 -0
  33. package/py/src/note_mcp/investigator/__init__.py +33 -0
  34. package/py/src/note_mcp/investigator/__main__.py +11 -0
  35. package/py/src/note_mcp/investigator/cli.py +313 -0
  36. package/py/src/note_mcp/investigator/core.py +653 -0
  37. package/py/src/note_mcp/investigator/mcp_tools.py +225 -0
  38. package/py/src/note_mcp/models.py +557 -0
  39. package/py/src/note_mcp/py.typed +0 -0
  40. package/py/src/note_mcp/server.py +905 -0
  41. package/py/src/note_mcp/utils/__init__.py +7 -0
  42. package/py/src/note_mcp/utils/file_parser.py +314 -0
  43. package/py/src/note_mcp/utils/html_to_markdown.py +477 -0
  44. package/py/src/note_mcp/utils/logging.py +119 -0
  45. package/py/src/note_mcp/utils/markdown.py +12 -0
  46. package/py/src/note_mcp/utils/markdown_to_html.py +826 -0
@@ -0,0 +1,63 @@
1
+ """HTTP entrypoint for note-connector ChatGPT connector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import uvicorn
10
+ from starlette.requests import Request
11
+ from starlette.responses import Response
12
+
13
+ from note_mcp.chatgpt.auth import load_or_create_token
14
+ from note_mcp.chatgpt.middleware import ConnectorAuthMiddleware
15
+ from note_mcp.chatgpt.tools import register_chatgpt_tools
16
+ from note_mcp.chatgpt.widgets import register_chatgpt_resources
17
+ from note_mcp.server import _session_manager, mcp
18
+
19
+
20
+ def _default_config_dir() -> Path:
21
+ return Path(os.environ.get("NOTE_CONNECTOR_CONFIG_DIR", Path.home() / ".note-connector"))
22
+
23
+
24
+ def _configure_mcp_allowed_hosts(host_header: str | None) -> None:
25
+ if host_header:
26
+ os.environ.setdefault("MCP_ALLOWED_HOSTS", host_header)
27
+ os.environ.setdefault("MCP_ALLOWED_ORIGINS", f"https://{host_header}")
28
+
29
+
30
+ def build_http_app(token: str) -> ConnectorAuthMiddleware:
31
+ register_chatgpt_tools(mcp, _session_manager)
32
+ register_chatgpt_resources(mcp)
33
+
34
+ @mcp.custom_route("/healthz", methods=["GET"])
35
+ async def healthz(_request: Request) -> Response:
36
+ from starlette.responses import JSONResponse
37
+
38
+ return JSONResponse({"status": "ok", "app": "note-connector"})
39
+
40
+ app = mcp.http_app(path="/mcp", transport="streamable-http", stateless_http=True)
41
+ return ConnectorAuthMiddleware(app, token)
42
+
43
+
44
+ def main() -> None:
45
+ parser = argparse.ArgumentParser(description="note-connector ChatGPT HTTP server")
46
+ parser.add_argument("--host", default="127.0.0.1")
47
+ parser.add_argument("--port", type=int, default=8787)
48
+ parser.add_argument("--token-file", type=Path, default=None)
49
+ args = parser.parse_args()
50
+
51
+ config_dir = _default_config_dir()
52
+ token_path = args.token_file or (config_dir / "token")
53
+ token = os.environ.get("NOTE_CONNECTOR_TOKEN") or load_or_create_token(token_path)
54
+
55
+ tunnel_host = os.environ.get("NOTE_CONNECTOR_TUNNEL_HOST")
56
+ _configure_mcp_allowed_hosts(tunnel_host)
57
+
58
+ app = build_http_app(token)
59
+ uvicorn.run(app, host=args.host, port=args.port, log_level="info")
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
@@ -0,0 +1,25 @@
1
+ """Record successful ChatGPT MCP access for note-connector CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+
10
+ def _access_path() -> Path:
11
+ base = Path(os.environ.get("NOTE_CONNECTOR_CONFIG_DIR", Path.home() / ".note-connector"))
12
+ return base / "last-mcp-access.json"
13
+
14
+
15
+ def record_mcp_access(remote_host: str | None) -> None:
16
+ """Append latest MCP access timestamp (idempotent overwrite)."""
17
+ path = _access_path()
18
+ path.parent.mkdir(parents=True, exist_ok=True)
19
+ payload = {
20
+ "at": datetime.now(UTC).isoformat(),
21
+ "remote": remote_host,
22
+ }
23
+ import json
24
+
25
+ path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
@@ -0,0 +1,52 @@
1
+ """Authentication helpers for the ChatGPT HTTP connector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from pathlib import Path
7
+
8
+ from starlette.requests import Request
9
+
10
+
11
+ def extract_bearer_token(request: Request) -> str | None:
12
+ """Extract auth token from Authorization header or ``?key=`` query."""
13
+ auth = request.headers.get("authorization")
14
+ if auth and auth.lower().startswith("bearer "):
15
+ return auth[7:].strip()
16
+ key = request.query_params.get("key")
17
+ if key:
18
+ return key.strip()
19
+ header_key = request.headers.get("x-note-connector-token")
20
+ if header_key:
21
+ return header_key.strip()
22
+ return None
23
+
24
+
25
+ def is_authorized(request: Request, expected_token: str) -> bool:
26
+ """Return whether the request may access protected routes."""
27
+ if request.url.path in ("/healthz", "/health"):
28
+ return True
29
+ if request.url.path.startswith("/assets/"):
30
+ return True
31
+ token = extract_bearer_token(request)
32
+ if token is None:
33
+ return False
34
+ return secrets.compare_digest(token, expected_token)
35
+
36
+
37
+ def load_or_create_token(token_path: Path) -> str:
38
+ """Load persisted connector token or create a new one."""
39
+ token_path.parent.mkdir(parents=True, exist_ok=True)
40
+ if token_path.is_file():
41
+ stored = token_path.read_text(encoding="utf-8").strip()
42
+ if stored:
43
+ return stored
44
+ token = secrets.token_urlsafe(32)
45
+ token_path.write_text(token, encoding="utf-8")
46
+ return token
47
+
48
+
49
+ def build_mcp_endpoint_url(public_base: str, token: str) -> str:
50
+ """Build the ChatGPT connector URL."""
51
+ base = public_base.rstrip("/")
52
+ return f"{base}/mcp?key={token}"
@@ -0,0 +1,92 @@
1
+ """Image materialization for ChatGPT-generated images."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import re
7
+ import uuid
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+
12
+ from note_mcp.api.images import validate_image_file
13
+
14
+ _MIME_TO_EXT = {
15
+ "image/jpeg": ".jpg",
16
+ "image/png": ".png",
17
+ "image/gif": ".gif",
18
+ "image/webp": ".webp",
19
+ }
20
+
21
+
22
+ def normalize_mime_type(mime_type: str) -> str:
23
+ """Normalize MIME type by stripping parameters."""
24
+ return mime_type.split(";", maxsplit=1)[0].strip().lower()
25
+
26
+
27
+ def extension_for_mime(mime_type: str) -> str:
28
+ """Return file extension for a supported image MIME type."""
29
+ normalized = normalize_mime_type(mime_type)
30
+ ext = _MIME_TO_EXT.get(normalized)
31
+ if ext is None:
32
+ raise ValueError(f"Unsupported image MIME type: {mime_type}")
33
+ return ext
34
+
35
+
36
+ async def download_image_url(url: str, dest_path: Path) -> None:
37
+ """Download image bytes from URL to dest_path."""
38
+ async with httpx.AsyncClient(follow_redirects=True, timeout=60.0) as client:
39
+ response = await client.get(url)
40
+ response.raise_for_status()
41
+ dest_path.write_bytes(response.content)
42
+
43
+
44
+ def write_base64_image(image_base64: str, dest_path: Path) -> None:
45
+ """Decode base64 image data and write to dest_path."""
46
+ cleaned = re.sub(r"\s+", "", image_base64)
47
+ if cleaned.startswith("data:") and "," in cleaned:
48
+ cleaned = cleaned.split(",", maxsplit=1)[1]
49
+ raw = base64.b64decode(cleaned, validate=True)
50
+ dest_path.write_bytes(raw)
51
+
52
+
53
+ def materialize_image_input_sync(
54
+ work_dir: Path,
55
+ *,
56
+ image_base64: str | None,
57
+ image_url: str | None,
58
+ mime_type: str,
59
+ ) -> Path:
60
+ """Write image input to a temp file and validate it."""
61
+ work_dir.mkdir(parents=True, exist_ok=True)
62
+ ext = extension_for_mime(mime_type)
63
+ dest = work_dir / f"chatgpt-{uuid.uuid4().hex}{ext}"
64
+ if image_base64:
65
+ write_base64_image(image_base64, dest)
66
+ elif image_url:
67
+ raise ValueError("image_url requires async materialize_image_input")
68
+ else:
69
+ raise ValueError("Provide image_base64 or image_url")
70
+ validate_image_file(str(dest))
71
+ return dest
72
+
73
+
74
+ async def materialize_image_input(
75
+ work_dir: Path,
76
+ *,
77
+ image_base64: str | None,
78
+ image_url: str | None,
79
+ mime_type: str,
80
+ ) -> Path:
81
+ """Write image input to a temp file and validate it."""
82
+ work_dir.mkdir(parents=True, exist_ok=True)
83
+ ext = extension_for_mime(mime_type)
84
+ dest = work_dir / f"chatgpt-{uuid.uuid4().hex}{ext}"
85
+ if image_base64:
86
+ write_base64_image(image_base64, dest)
87
+ elif image_url:
88
+ await download_image_url(image_url, dest)
89
+ else:
90
+ raise ValueError("Provide image_base64 or image_url")
91
+ validate_image_file(str(dest))
92
+ return dest
@@ -0,0 +1,26 @@
1
+ """One-shot note.com browser login for note-connector onboarding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from note_mcp.auth.browser import login_with_browser
8
+ from note_mcp.auth.session import SessionManager
9
+
10
+
11
+ async def _main() -> int:
12
+ if SessionManager().has_session():
13
+ print("note.com: already authenticated")
14
+ return 0
15
+ print("Opening browser for note.com login…")
16
+ await login_with_browser(timeout=300)
17
+ print("note.com: login saved")
18
+ return 0
19
+
20
+
21
+ def main() -> None:
22
+ raise SystemExit(asyncio.run(_main()))
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
@@ -0,0 +1,31 @@
1
+ """ASGI middleware for connector authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from starlette.requests import Request
6
+ from starlette.responses import JSONResponse
7
+ from starlette.types import ASGIApp, Receive, Scope, Send
8
+
9
+ from note_mcp.chatgpt.access_log import record_mcp_access
10
+ from note_mcp.chatgpt.auth import is_authorized
11
+
12
+
13
+ class ConnectorAuthMiddleware:
14
+ """Reject unauthorized requests to MCP endpoints."""
15
+
16
+ def __init__(self, app: ASGIApp, token: str) -> None:
17
+ self.app = app
18
+ self.token = token
19
+
20
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
21
+ if scope["type"] != "http":
22
+ await self.app(scope, receive, send)
23
+ return
24
+ request = Request(scope, receive=receive)
25
+ if not is_authorized(request, self.token):
26
+ response = JSONResponse({"error": "unauthorized"}, status_code=401)
27
+ await response(scope, receive, send)
28
+ return
29
+ if request.method == "POST" and request.url.path.startswith("/mcp"):
30
+ record_mcp_access(request.client.host if request.client else None)
31
+ await self.app(scope, receive, send)
@@ -0,0 +1,255 @@
1
+ """Additional MCP tools for ChatGPT connector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Annotated, Any
8
+
9
+ from fastmcp import FastMCP
10
+
11
+ from note_mcp.api.articles import create_draft, delete_article, list_articles, unpublish_article
12
+ from note_mcp.api.images import insert_image_via_api
13
+ from note_mcp.api.public_notes import fetch_public_article, search_public_notes
14
+ from note_mcp.auth.session import SessionManager
15
+ from note_mcp.chatgpt.images import materialize_image_input
16
+ from note_mcp.chatgpt.widgets import (
17
+ ARTICLE_PANEL_URI,
18
+ HOME_URI,
19
+ widget_tool_meta,
20
+ )
21
+ from note_mcp.models import ArticleInput, ArticleStatus, NoteAPIError
22
+
23
+
24
+ def register_chatgpt_tools(mcp: FastMCP, session_manager: SessionManager) -> None:
25
+ """Register ChatGPT-specific tools and widget resources."""
26
+
27
+ @mcp.tool(
28
+ meta=widget_tool_meta(HOME_URI, "note-connector を開いています…", "note-connector"),
29
+ annotations={"readOnlyHint": True},
30
+ )
31
+ async def note_ui_status() -> dict[str, object]:
32
+ """ChatGPT用: 認証状態とクイック情報を返します(Apps SDK ウィジェット用 dict)。"""
33
+ if not session_manager.has_session():
34
+ return {"authenticated": False, "message": "note_login を実行してください"}
35
+ session = session_manager.load()
36
+ if session is None or session.is_expired():
37
+ return {"authenticated": False, "message": "セッションが無効です。note_login を実行してください"}
38
+ return {
39
+ "authenticated": True,
40
+ "message": f"ユーザー: {session.username}",
41
+ "username": session.username,
42
+ }
43
+
44
+ @mcp.tool(
45
+ meta=widget_tool_meta(ARTICLE_PANEL_URI, "記事一覧を取得中…", "記事一覧"),
46
+ annotations={"readOnlyHint": True},
47
+ )
48
+ async def note_ui_list_articles(
49
+ status: Annotated[str | None, "draft/published/all"] = None,
50
+ page: Annotated[int, "ページ番号"] = 1,
51
+ limit: Annotated[int, "1ページあたり件数"] = 10,
52
+ ) -> dict[str, object]:
53
+ """ChatGPT用: 記事一覧をウィジェット表示用 dict で返します。"""
54
+ session = session_manager.load()
55
+ if session is None or session.is_expired():
56
+ return {"articles": [], "error": "未認証。note_login を実行してください", "authenticated": False}
57
+
58
+ status_filter: ArticleStatus | None = None
59
+ if status is not None and status != "all":
60
+ status_filter = ArticleStatus(status)
61
+
62
+ try:
63
+ result = await list_articles(session, status=status_filter, page=page, limit=limit)
64
+ except NoteAPIError as exc:
65
+ return {"articles": [], "error": str(exc), "authenticated": True}
66
+
67
+ articles: list[dict[str, Any]] = []
68
+ for article in result.articles:
69
+ articles.append(
70
+ {
71
+ "id": article.id,
72
+ "key": article.key,
73
+ "title": article.title,
74
+ "status": article.status.value,
75
+ }
76
+ )
77
+ return {
78
+ "articles": articles,
79
+ "total": result.total,
80
+ "page": result.page,
81
+ "has_more": result.has_more,
82
+ "authenticated": True,
83
+ }
84
+
85
+ @mcp.tool()
86
+ async def note_attach_image(
87
+ article_key: Annotated[str, "記事キー(n... 形式)"],
88
+ mime_type: Annotated[str, "image/png など"],
89
+ image_base64: Annotated[str | None, "Base64画像データ"] = None,
90
+ image_url: Annotated[str | None, "画像URL"] = None,
91
+ caption: Annotated[str | None, "キャプション"] = None,
92
+ ) -> str:
93
+ """ChatGPT生成画像を記事に挿入します(base64 または URL)。"""
94
+ session = session_manager.load()
95
+ if session is None or session.is_expired():
96
+ return "セッションが無効です。note_loginでログインしてください。"
97
+
98
+ work_dir = Path(os.environ.get("NOTE_CONNECTOR_WORK_DIR", "/tmp/note-connector"))
99
+ try:
100
+ file_path = await materialize_image_input(
101
+ work_dir,
102
+ image_base64=image_base64,
103
+ image_url=image_url,
104
+ mime_type=mime_type,
105
+ )
106
+ result = await insert_image_via_api(
107
+ session=session,
108
+ article_id=article_key,
109
+ file_path=str(file_path),
110
+ caption=caption,
111
+ )
112
+ except (ValueError, NoteAPIError, OSError) as exc:
113
+ return f"画像挿入に失敗しました: {exc}"
114
+
115
+ return f"画像を挿入しました。記事キー: {result['article_key']}\n画像URL: {result['image_url']}"
116
+
117
+ @mcp.tool()
118
+ async def note_create_draft_with_images(
119
+ title: Annotated[str, "記事タイトル"],
120
+ body: Annotated[str, "本文(Markdown)"],
121
+ tags: Annotated[list[str] | None, "タグ"] = None,
122
+ images: Annotated[
123
+ list[dict[str, str]] | None,
124
+ "画像配列。各要素: mime_type, image_base64 または image_url",
125
+ ] = None,
126
+ ) -> str:
127
+ """下書き作成後、ChatGPT画像を本文に挿入します。"""
128
+ session = session_manager.load()
129
+ if session is None or session.is_expired():
130
+ return "セッションが無効です。note_loginでログインしてください。"
131
+
132
+ article_input = ArticleInput(title=title, body=body, tags=tags or [])
133
+ try:
134
+ article = await create_draft(session, article_input)
135
+ except NoteAPIError as exc:
136
+ return f"下書き作成に失敗しました: {exc}"
137
+
138
+ inserted = 0
139
+ errors: list[str] = []
140
+ work_dir = Path(os.environ.get("NOTE_CONNECTOR_WORK_DIR", "/tmp/note-connector"))
141
+ for index, image_spec in enumerate(images or []):
142
+ mime = image_spec.get("mime_type", "image/png")
143
+ b64 = image_spec.get("image_base64")
144
+ url = image_spec.get("image_url")
145
+ caption = image_spec.get("caption")
146
+ try:
147
+ file_path = await materialize_image_input(
148
+ work_dir,
149
+ image_base64=b64,
150
+ image_url=url,
151
+ mime_type=mime,
152
+ )
153
+ await insert_image_via_api(
154
+ session=session,
155
+ article_id=article.key,
156
+ file_path=str(file_path),
157
+ caption=caption,
158
+ )
159
+ inserted += 1
160
+ except (ValueError, NoteAPIError, OSError) as exc:
161
+ errors.append(f"image[{index}]: {exc}")
162
+
163
+ lines = [
164
+ f"下書きを作成しました。ID: {article.id}、キー: {article.key}",
165
+ f"挿入した画像: {inserted}件",
166
+ ]
167
+ if errors:
168
+ lines.append("画像エラー:")
169
+ lines.extend(f" - {e}" for e in errors)
170
+ return "\n".join(lines)
171
+
172
+ @mcp.tool(annotations={"readOnlyHint": True})
173
+ async def note_search_public_articles(
174
+ query: Annotated[str, "検索キーワード"],
175
+ size: Annotated[int, "件数(1〜20)"] = 10,
176
+ ) -> dict[str, object]:
177
+ """note.com 上の公開記事をキーワード検索します(ログイン不要)。"""
178
+ try:
179
+ result = await search_public_notes(query, size=size)
180
+ except NoteAPIError as exc:
181
+ return {"items": [], "error": str(exc), "query": query}
182
+ return {
183
+ "query": result.query,
184
+ "is_last_page": result.is_last_page,
185
+ "items": [item.model_dump() for item in result.items],
186
+ }
187
+
188
+ @mcp.tool(annotations={"readOnlyHint": True})
189
+ async def note_fetch_public_article(
190
+ note_key_or_url: Annotated[str, "記事キー(n...)または公開URL"],
191
+ ) -> dict[str, object]:
192
+ """他人の公開記事を取得します(ログイン不要)。"""
193
+ try:
194
+ article = await fetch_public_article(note_key_or_url)
195
+ except NoteAPIError as exc:
196
+ return {"error": str(exc)}
197
+ return article.model_dump()
198
+
199
+ @mcp.tool()
200
+ async def note_delete_article(
201
+ article_key: Annotated[str, "削除する記事のキー(例: n1234567890ab)"],
202
+ confirm: Annotated[bool, "削除を実行する場合はTrue、確認のみの場合はFalse"] = False,
203
+ ) -> dict[str, object]:
204
+ """公開記事を含む任意の記事を削除します(下書き・公開問わず削除可、取り消し不可)。"""
205
+ session = session_manager.load()
206
+ if session is None or session.is_expired():
207
+ return {"error": "セッションが無効です。note_loginでログインしてください。"}
208
+
209
+ try:
210
+ result = await delete_article(session, article_key, confirm=confirm)
211
+ except NoteAPIError as exc:
212
+ return {"error": str(exc)}
213
+
214
+ from note_mcp.models import DeletePreview, DeleteResult
215
+
216
+ if isinstance(result, DeletePreview):
217
+ return {
218
+ "action": "preview",
219
+ "article_title": result.article_title,
220
+ "article_key": result.article_key,
221
+ "status": result.status.value,
222
+ "message": result.message,
223
+ }
224
+ elif isinstance(result, DeleteResult):
225
+ return {
226
+ "action": "deleted",
227
+ "success": result.success,
228
+ "article_title": result.article_title,
229
+ "article_key": result.article_key,
230
+ "message": result.message,
231
+ }
232
+ return {"result": str(result)}
233
+
234
+ @mcp.tool()
235
+ async def note_unpublish_article(
236
+ article_key: Annotated[str, "下書きに戻す公開記事のキー(例: n1234567890ab)"],
237
+ ) -> dict[str, object]:
238
+ """公開記事を下書きに戻します(記事内容は保持されます)。"""
239
+ session = session_manager.load()
240
+ if session is None or session.is_expired():
241
+ return {"error": "セッションが無効です。note_loginでログインしてください。"}
242
+
243
+ try:
244
+ article = await unpublish_article(session, article_key)
245
+ except NoteAPIError as exc:
246
+ return {"error": str(exc)}
247
+
248
+ return {
249
+ "unpublished": True,
250
+ "title": article.title,
251
+ "key": article.key,
252
+ "status": "draft",
253
+ "url": article.url,
254
+ "message": f"記事「{article.title}」を下書きに戻しました。",
255
+ }
@@ -0,0 +1,121 @@
1
+ """Apps SDK HTML widgets for note-connector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ HOME_URI = "ui://note-connector/home-v1.html"
6
+ ARTICLE_PANEL_URI = "ui://note-connector/article-panel-v1.html"
7
+
8
+ APPS_MIME = "text/html;profile=mcp-app"
9
+
10
+ WIDGET_RUNTIME = """
11
+ function noteConnectorParsePayload(payload) {
12
+ if (!payload || typeof payload !== 'object') return {};
13
+ if (typeof payload.result === 'string') {
14
+ try { var inner = JSON.parse(payload.result); if (inner && typeof inner === 'object') return inner; } catch (e) {}
15
+ }
16
+ if (payload.data && typeof payload.data === 'object') return payload.data;
17
+ return payload;
18
+ }
19
+ (function() {
20
+ function boot() {
21
+ const openai = window.openai || {};
22
+ const raw = openai.toolOutput || openai.structuredContent || {};
23
+ const data = noteConnectorParsePayload(raw);
24
+ const callTool = (name, args) => openai.callTool && openai.callTool(name, args);
25
+ const ctx = { data: data, callTool: callTool };
26
+ if (typeof window.NoteConnectorRender === 'function') window.NoteConnectorRender(ctx);
27
+ }
28
+ window.addEventListener('openai:set_globals', boot);
29
+ boot();
30
+ })();
31
+ """
32
+
33
+
34
+ def _widget_document(title: str, body: str, render_js: str) -> str:
35
+ return f"""<!DOCTYPE html>
36
+ <html lang="ja">
37
+ <head>
38
+ <meta charset="utf-8" />
39
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
40
+ <title>{title}</title>
41
+ <style>
42
+ body {{ margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; }}
43
+ .root {{ padding: 12px; font-size: 13px; }}
44
+ .title {{ font-weight: 600; margin-bottom: 8px; }}
45
+ .mono {{ font-family: ui-monospace, monospace; white-space: pre-wrap; }}
46
+ .muted {{ color: #666; }}
47
+ .card {{ border: 1px solid #ddd; border-radius: 8px; padding: 10px; margin: 8px 0; }}
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <div id="root" class="root">{body}</div>
52
+ <script>{render_js}</script>
53
+ <script>{WIDGET_RUNTIME}</script>
54
+ </body>
55
+ </html>"""
56
+
57
+
58
+ def home_widget_html() -> str:
59
+ render = """
60
+ window.NoteConnectorRender = function(ctx) {
61
+ const root = document.getElementById('root');
62
+ const d = ctx.data || {};
63
+ root.innerHTML = '<div class="title">note-connector</div>'
64
+ + '<div class="muted">ChatGPT connector for note.com</div>'
65
+ + '<div class="card"><div>認証: ' + (d.authenticated ? 'OK' : '未ログイン') + '</div>'
66
+ + '<div class="small">' + (d.message || '') + '</div></div>';
67
+ };
68
+ """
69
+ return _widget_document("note-connector Home", '<div class="muted">Loading…</div>', render)
70
+
71
+
72
+ def article_panel_widget_html() -> str:
73
+ render = """
74
+ window.NoteConnectorRender = function(ctx) {
75
+ const root = document.getElementById('root');
76
+ const d = ctx.data || {};
77
+ const articles = d.articles || [];
78
+ if (!articles.length) {
79
+ root.innerHTML = '<div class="muted">記事がありません</div>';
80
+ return;
81
+ }
82
+ root.innerHTML = '<div class="title">記事一覧 (' + articles.length + ')</div>'
83
+ + articles.map(function(a) {
84
+ return '<div class="card"><strong>' + (a.title || '') + '</strong><br/>'
85
+ + '<span class="mono">' + (a.id || '') + ' / ' + (a.key || '') + '</span><br/>'
86
+ + '<span class="muted">' + (a.status || '') + '</span></div>';
87
+ }).join('');
88
+ };
89
+ """
90
+ return _widget_document("note-connector Articles", '<div class="muted">Loading…</div>', render)
91
+
92
+
93
+ def widget_tool_meta(uri: str, invoking: str, invoked: str) -> dict[str, object]:
94
+ return {
95
+ "ui": {"resourceUri": uri},
96
+ "openai/outputTemplate": uri,
97
+ "openai/toolInvocation/invoking": invoking,
98
+ "openai/toolInvocation/invoked": invoked,
99
+ "openai/widgetAccessible": True,
100
+ }
101
+
102
+
103
+ def register_chatgpt_resources(mcp: object) -> None:
104
+ """Register widget HTML as MCP resources."""
105
+ from fastmcp import FastMCP
106
+
107
+ server = mcp
108
+ if not isinstance(server, FastMCP):
109
+ return
110
+
111
+ @server.resource(HOME_URI, mime_type=APPS_MIME, meta={"openai/widgetDescription": "note-connector home"})
112
+ def note_home_resource() -> str:
113
+ return home_widget_html()
114
+
115
+ @server.resource(
116
+ ARTICLE_PANEL_URI,
117
+ mime_type=APPS_MIME,
118
+ meta={"openai/widgetDescription": "note.com article list"},
119
+ )
120
+ def note_article_panel_resource() -> str:
121
+ return article_panel_widget_html()