Install
$ agentstack add skill-michelkerkmeester-skilled-harness-spec-driven-agent-loops-mcp-notion ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ● Filesystem access Used
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
mcp-notion Skill
Notion workspace operations via the official Notion MCP (@notionhq/notion-mcp-server, 24 tools) over Code Mode, plus direct Notion API calls for the five capabilities the MCP does not expose. It knows Notion's data model at the schema layer — data sources, 22 property types, relations, rollups, Formulas 2.0 — so an agent can create, query, and extend a workspace without guessing.
MARKDOWN + DATA-SOURCE CONTRACT — READ BEFORE ANY NOTION WRITE
Two shape rules cause most Notion write failures:
- Page bodies vs page properties are different surfaces. A page's content is a tree of blocks (append/update via block tools, or the markdown round-trip tools). A page's metadata is properties typed by its parent data source's schema. Writing prose into a property, or a property value into a block, fails or silently truncates.
- API 2.0 replaced "databases" with "data sources" (API version
2025-09-03). A database is now a container of one or more data sources; queries, schema, relations, and rollups all target a data source id, not a database id. Using a database id where a data-source id is required is the most common 400.
Markdown round-trip tools (retrieve-page-markdown / update-page-markdown) require API version 2026-03-11 and are the token-efficient path for AI. Full detail: references/database-model.md and references/mcp-tools.md.
Failure symptom: a validation_error naming data_source_id, or property writes that vanish, means a database id was used where a data-source id was required, or content was routed to the wrong surface.
1. WHEN TO USE
Activation Triggers (explicit user phrases)
- "notion", "notion mcp", "notion api", "mcp-notion"
- "notion page", "notion database", "notion data source", "query notion"
- "create a notion page", "add a row", "notion property", "notion relation", "notion rollup", "notion formula"
- "notion token", "notion integration", "ntn_", "notion search"
- "upload a file to notion", "notion view", "notion async task"
Automatic Triggers (keyword patterns)
notion+ any action verb (create, read, update, query, search, append, archive)- "data source" / "property" / "rollup" / "relation" in a Notion context
- MCP tool names:
create-a-page,query-data-source,retrieve-a-page(namespacednotion.notion_)
When NOT to Use
- Community Notion MCP servers (suekou/mcp-notion-server, awkoy/notion-mcp-server) — this mode uses the official
@notionhq/notion-mcp-serveronly. - Notion→Obsidian migration mechanics (running the importer itself) — the importer runs inside the Obsidian desktop app, not here; this mode is the read-side inventory enabler (
references/migration-inventory.md), not the importer. - Generic markdown authoring with no Notion workspace — use
@markdown/sk-doc. - Non-Notion knowledge apps (Obsidian, ClickUp docs) — wrong surface (
mcp-obsidian,mcp-click-up).
2. SMART ROUTING
Resource Loading Levels
ALWAYS: SKILL.md (this file)
ON_DEMAND: references/mcp-tools.md (24-tool catalog + Code Mode invocation)
references/api-gap-tools.md (direct API for the 5 uncovered capabilities)
references/property-types.md (22 property types: schema, value, filter/sort)
references/database-model.md (data-source hierarchy, relations, rollups, Formulas 2.0)
references/troubleshooting.md (auth, rate-limit, version, deprecation-migration)
references/migration-inventory.md (Notion→Obsidian migration read-side inventory method)
Two Decisions This Router Makes
- MCP vs direct API — anything in the 24-tool surface (page/block/data-source/comment/user/search CRUD) goes through the MCP over Code Mode; the five gaps (file uploads, views, page property items, async tasks, daily-notes convention) go to direct Notion API calls.
- Which backend — headless local stdio (
npx @notionhq/notion-mcp-server,NOTION_TOKEN) for Code Mode / automated sessions; remote OAuth (mcp.notion.com) only when an interactive browser session is available. Code Mode is headless, so it uses the local stdio backend.
Backend Selection
def resolve_notion_backend(runtime):
"""Pick the Notion backend. Probe, never assume.
runtime.interactive -> a browser/OAuth session is available
runtime.oauth_token -> remote Notion MCP OAuth completed
runtime.notion_token -> NOTION_TOKEN (ntn_...) present for Code Mode
"""
# Remote MCP is OAuth-only and CANNOT run headless. Prefer it only when a
# human/browser session is present; it adds async-task tools the local lacks.
if runtime.interactive and runtime.oauth_token:
return "REMOTE_MCP" # https://mcp.notion.com/mcp (Streamable HTTP + OAuth)
# Default for Code Mode and any automated session: the local stdio server.
# Deprecated by Notion but the only headless-capable backend.
if runtime.notion_token:
return "LOCAL_STDIO" # notion manual in .utcp_config.json, via Code Mode
return "ESCALATE" # no auth — direct the user to INSTALL-GUIDE.md
Operation-to-Tool Routing Table
| Operation | Surface | Tool / call | Notes | |---|---|---|---| | Create / retrieve / update / archive a page | MCP | create-a-page / retrieve-a-page / update-page-properties / archive-a-page | Archive, not hard delete | | Page body as markdown (read/write) | MCP | retrieve-page-markdown / update-page-markdown | Needs API 2026-03-11; token-efficient | | Append / update / delete blocks | MCP | block tools (append-block-children, update-a-block, delete-a-block, …) | Page content surface | | Query / retrieve / update a data source | MCP | query-data-source + data-source tools | Target the data-source id | | Comments (create, list) | MCP | comment tools | — | | Users (list, retrieve, bot) | MCP | user tools | — | | Search (by title) | MCP | search | Title-only; no full-text content search | | File uploads | direct API | POST /v1/file_uploads (+ send/complete) | Not in MCP — see api-gap-tools.md | | Views (create/list/query) | direct API | data-source view endpoints | Not in MCP | | Page property items (non-truncated) | direct API | GET /v1/pages/{id}/properties/{prop} | Not in MCP | | Async tasks (poll) | direct API / remote MCP | task-status endpoint | Native on remote MCP only | | Daily notes | convention | knowledge-layer pattern | No API — see database-model.md |
Smart Router Pseudocode
from pathlib import Path
SKILL_ROOT = Path(__file__).resolve().parent
RESOURCE_BASES = (SKILL_ROOT / "references",)
DEFAULT_RESOURCE = "references/mcp-tools.md"
# Fallback-only: DEFAULT_RESOURCE is a defer-time suggestion, never unioned into a
# route's loaded set. Scored routes load exactly RESOURCE_MAP[intent]; zero-score
# routes load nothing and ask for disambiguation instead.
DEFAULT_RESOURCE_SEMANTICS = "fallback-only"
UNKNOWN_FALLBACK_CHECKLIST = [
"Confirm whether the request is Notion page/block ops, data-source/schema ops, an API-gap capability, install/auth, or troubleshooting",
"Provide the page id, data-source id, property name, or error text",
"Confirm whether NOTION_TOKEN (headless/Code Mode) or a remote OAuth session is available",
"Confirm the verification command before completing any write action",
]
INTENT_SIGNALS = {
"NOTION_PAGES": {
"weight": 5,
"keywords": ["page", "pages", "block", "blocks", "append", "markdown", "content",
"create page", "retrieve page", "update page", "archive", "comment",
"user", "search", "title search", "sub-page", "child page"],
},
"NOTION_DATA": {
"weight": 5,
"keywords": ["database", "data source", "datasource", "query", "row", "rows",
"relation", "rollup", "formula", "schema", "property", "filter",
"sort", "data_source_id", "two-way relation", "aggregate"],
},
"NOTION_API_GAP": {
"weight": 6,
"keywords": ["file upload", "upload a file", "view", "views", "property item",
"non-truncated", "async task", "poll task", "daily note"],
},
"NOTION_KNOWLEDGE": {
"weight": 5,
"keywords": ["property type", "property types", "select", "multi-select", "status",
"formula function", "rollup function", "data model", "hierarchy"],
},
"NOTION_MIGRATION": {
"weight": 5,
"keywords": ["migration", "migrate", "migration inventory", "notion import",
"obsidian import", "workspace inventory", "pre-migration inventory",
"relation recovery", "rollup recovery", "comment reconstruction",
"parity verification"],
},
"INSTALL": {
"weight": 6,
"keywords": ["install", "setup", "not found", "not installed", "notion token",
"ntn_", "integration token", "api token", "mcp config", "register",
"configure", "configuration", "getting started", "onboarding",
"oauth", "connect notion", "how do i install"],
},
"TROUBLESHOOT": {
"weight": 6,
"keywords": ["error", "failed", "not working", "401", "403", "429", "400",
"rate limit", "unauthorized", "forbidden", "slow", "timeout",
"validation_error", "data_source_id", "deprecated", "won't connect",
"can't connect", "version mismatch", "object_not_found"],
},
}
# NOTE: no "DEFAULT" entry — route_notion_resources() never indexes RESOURCE_MAP by
# that key; the selected `intent` is always one of the seven INTENT_SIGNALS keys. The
# no-match case is owned by DEFAULT_RESOURCE, whose fallback-only semantics mean it is
# SUGGESTED beside the disambiguation checklist, never loaded — so mcp-tools.md cannot
# leak into the DATA / API_GAP / KNOWLEDGE / MIGRATION / INSTALL / TROUBLESHOOT routes.
RESOURCE_MAP = {
"NOTION_PAGES": ["references/mcp-tools.md"],
"NOTION_DATA": ["references/database-model.md", "references/property-types.md",
"references/mcp-tools.md"],
"NOTION_API_GAP": ["references/api-gap-tools.md"],
"NOTION_KNOWLEDGE": ["references/property-types.md", "references/database-model.md"],
"NOTION_MIGRATION": ["references/migration-inventory.md"],
"INSTALL": ["references/troubleshooting.md"],
"TROUBLESHOOT": ["references/troubleshooting.md"],
}
def discover_markdown_resources() -> set[str]:
docs = []
for base in RESOURCE_BASES:
if base.exists():
docs.extend(path for path in base.rglob("*.md") if path.is_file())
return {doc.relative_to(SKILL_ROOT).as_posix() for doc in docs}
def _guard_in_skill(relative_path: str) -> str:
resolved = (SKILL_ROOT / relative_path).resolve()
resolved.relative_to(SKILL_ROOT)
if resolved.suffix.lower() != ".md":
raise ValueError(f"Only markdown skill resources are routable: {relative_path}")
return resolved.relative_to(SKILL_ROOT).as_posix()
def load_if_available(relative_path, loaded, seen, inventory) -> None:
guarded = _guard_in_skill(relative_path)
if guarded in inventory and guarded not in seen:
load(guarded)
loaded.append(guarded)
seen.add(guarded)
def route_notion_resources(request: str) -> dict:
"""Score intent labels and load available Notion reference docs."""
inventory = discover_markdown_resources()
loaded, seen = [], set()
request_lower = request.lower()
scores = {}
for intent, config in INTENT_SIGNALS.items():
score = sum(config["weight"] for kw in config["keywords"] if kw in request_lower)
if score > 0:
scores[intent] = score
if not scores:
return {
"load_level": "UNKNOWN_FALLBACK",
"needs_disambiguation": True,
"disambiguation_checklist": UNKNOWN_FALLBACK_CHECKLIST,
"suggested_fallback": DEFAULT_RESOURCE,
"resources": loaded,
}
# Error/install keywords win regardless of other signals.
if scores.get("TROUBLESHOOT", 0) > 3:
intent = "TROUBLESHOOT"
elif scores.get("INSTALL", 0) > 4:
intent = "INSTALL"
else:
intent = max(scores, key=scores.get)
for resource in RESOURCE_MAP[intent]:
load_if_available(resource, loaded, seen, inventory)
if not loaded:
return {
"load_level": "UNKNOWN_FALLBACK",
"notice": f"No Notion reference docs available for intent '{intent}'",
"disambiguation_checklist": UNKNOWN_FALLBACK_CHECKLIST,
"suggested_fallback": DEFAULT_RESOURCE,
"resources": loaded,
}
return {"intent": intent, "resources": loaded}
3. HOW IT WORKS
Backend Comparison
| Dimension | Local stdio (default, headless) | Remote MCP (interactive) | |---|---|---| | Transport | stdio via npx -y @notionhq/notion-mcp-server | Streamable HTTP at https://mcp.notion.com/mcp | | Auth | NOTION_TOKEN (ntn_… internal-integration token) | OAuth (browser) | | Headless? | Yes — the only Code-Mode-capable backend | No — interactive only | | Tool names | create-a-page, retrieve-a-page, … | notion-create-pages, … + async tasks | | Status | Deprecated by Notion, still functional | Recommended by Notion | | Used by | Code Mode / automation | Human-in-the-loop clients |
> Deprecation note. Notion is deprecating the open-source local server in favor of the remote OAuth server. Code Mode is headless, so the local stdio server is the correct (and only) choice here today; references/troubleshooting.md carries the local→remote migration path for when the operator moves to an interactive workflow.
Official Notion MCP — via Code Mode (default path)
The registered notion manual launches @notionhq/notion-mcp-server over stdio with npx -y. It talks to Notion's REST API with the integration's NOTION_TOKEN.
Prerequisites:
- Code Mode MCP configured, with the
notionmanual in.utcp_config.json(already registered — see INSTALL-GUIDE). notion_NOTION_TOKEN(anntn_…internal-integration token) available to Code Mode, and the integration granted content access to the target pages/data sources.
Configuration (.utcp_config.json, manual_call_templates) — already applied:
{
"name": "notion",
"call_template_type": "mcp",
"config": {
"mcpServers": {
"notion": {
"transport": "stdio",
"command": "npx",
"args": ["-y", "@notionhq/notion-mcp-server"],
"env": { "NOTION_TOKEN": "${notion_NOTION_TOKEN}" }
}
}
}
}
The notion_ env prefix matches the manual name notion, so ${notion_NOTION_TOKEN} resolves. This mode documents that registration; it does not rewrite config files.
Tools: the server exposes 24 notion_* tools across 6 domains — pages (7), blocks (5), data sources (6), comments (2), users (3), search (1). Confirm every name with tool_info() / list_tools() before calling; the full catalog with per-tool inputs is in references/mcp-tools.md.
Invocation via Code Mode (call_tool_chain takes a single code string):
// Code Mode namespaces each tool as notion.notion_. Notion tool names are
// HYPHENATED (create-a-page, retrieve-a-page), so notion.notion_retrieve-a-page is invalid
// JS (it parses as subtraction) — use hyphen-safe BRACKET access. VERIFY once the manual is
// registered: run list_tools() to read the exact callable; Code Mode MAY instead sanitize
// hyphens to underscores (notion.notion_retrieve_a_page). Bracket form is the safe default.
cons
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [MichelKerkmeester](https://github.com/MichelKerkmeester)
- **Source:** [MichelKerkmeester/skilled-harness__spec-driven-agent-loops](https://github.com/MichelKerkmeester/skilled-harness__spec-driven-agent-loops)
- **License:** MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.