feat(notion): add v1 OAuth backend alongside v3 cookie path#180
Conversation
The existing notion integration talks to www.notion.so/api/v3 with a browser session cookie (token_v2). That path still works for users who set it up before OAuth existed, but it's brittle (cookies expire, Notion can change v3 without notice) and unusable for hosted SaaS. This adds a parallel notionV1 backend that uses Notion's documented public REST API (api.notion.com/v1) with an OAuth bearer token and the date-stamped Notion-Version header (2025-09-03, the version that introduced the data_sources model so the public surface matches v3's database/data-source split naturally). Backend selection happens in Configure() based on which credential key is set: access_token → v1 (OAuth, hosted-friendly) token_v2 → v3 (cookie, self-host legacy) If both are present, v1 wins. Execute() and Healthy() delegate to v1 when it's configured; otherwise the existing v3 path runs unchanged. All tools that the v3 backend exposes have a v1 equivalent, dispatched via dispatchV1. Tests cover the configure handshake, header shape, and each handler family (data sources, pages, blocks, search, users, comments, convenience helpers). No behavior change for existing token_v2 users.
acmacalister
left a comment
There was a problem hiding this comment.
Good stuff overall — the dual-backend design is clean, the test coverage is thorough, build and lint are both green. Four things worth tidying up inline.
| // Tools that have no v1 equivalent (none today, but reserved for future | ||
| // v3-only surfaces) should be left out — notion.Execute returns a | ||
| // "not supported on v1 backend" error for missing entries. | ||
| var dispatchV1 = map[mcp.ToolName]v1HandlerFunc{ |
There was a problem hiding this comment.
dispatchV1 is missing the two parity tests every dispatch map in this project is required to have. The v3 dispatch has TestDispatchMap_EveryToolHasHandler and TestDispatchMap_NoOrphanHandlers in notion_test.go — dispatchV1 needs equivalents.
Right now, nothing enforces that every entry in dispatchV1 has a corresponding ToolDefinition in Tools(), or vice versa. If a tool name is misspelled here or a new tool is added to Tools() without a v1 handler, it silently routes to the "not yet supported on v1 backend" error at runtime instead of failing at test time.
func TestDispatchV1_AllToolsCovered(t *testing.T) {
i := New()
for _, tool := range i.Tools() {
_, ok := dispatchV1[tool.Name]
assert.True(t, ok, "tool %s has no v1 dispatch handler", tool.Name)
}
}
func TestDispatchV1_NoOrphanHandlers(t *testing.T) {
i := New()
toolNames := make(map[mcp.ToolName]bool)
for _, tool := range i.Tools() {
toolNames[tool.Name] = true
}
for name := range dispatchV1 {
assert.True(t, toolNames[name], "v1 dispatch handler %s has no tool definition", name)
}
}There was a problem hiding this comment.
Done in 785138e — added both TestDispatchV1_EveryToolHasHandler and TestDispatchV1_NoOrphanHandlers in v1_test.go, matching the v3 pattern.
|
|
||
| // Prefer data_sources; fall back to databases for older IDs. | ||
| data, err := n.patch(ctx, "/data_sources/"+id, body) | ||
| if err != nil { |
There was a problem hiding this comment.
This fallback triggers on any error from /data_sources/, including transient ones. If Notion rate-limits the request (429), doRequest returns a *mcp.RetryableError — but instead of propagating it so the runtime can back off, we swallow it and immediately fire a second request to /databases/. Same issue exists in v1QueryDataSource (line 164) and v1FetchDatabaseOrDataSource (line 199).
The fallback only makes sense for 404-style "this ID is not a data source" responses. A guard like this preserves the intent while letting retryable errors escape:
data, err := n.patch(ctx, "/data_sources/"+id, body)
if err != nil {
var re *mcp.RetryableError
if errors.As(err, &re) {
return mcp.ErrResult(err)
}
data, err = n.patch(ctx, "/databases/"+id, body)
if err != nil {
return mcp.ErrResult(err)
}
}There was a problem hiding this comment.
Good catch. Fixed in 785138e — added an errors.As guard on *mcp.RetryableError to all three fallback sites (v1UpdateDataSource, v1QueryDataSource, v1FetchDatabaseOrDataSource). Rate-limits and 5xx now propagate so the runtime can back off; only non-retryable errors fall through to the /databases/ retry.
| // convenience title. Page-parented pages use the literal "title"; rows | ||
| // in a database use whatever the database's title column is named, but | ||
| // the caller must specify that — we default to "title" if unknown. | ||
| func v1TitleKeyForParent(parent map[string]any) string { |
There was a problem hiding this comment.
Both branches of v1TitleKeyForParent return "title", so the condition is never evaluated. The comment above acknowledges that database rows use a user-defined title column name, but the code doesn't implement that distinction — it just returns "title" unconditionally.
Either inline the literal "title" at the call site and delete this function, or implement the differentiation the comment describes. As written, the function misleads readers into thinking there's conditional logic here.
There was a problem hiding this comment.
You're right, the function was dead. Removed in 785138e and inlined const titleKey = "title" at the call site, with the comment updated to reflect what actually happens (default to title; callers can override via the properties map for database rows with a renamed title column).
| // so v1 handlers don't have to reach for the legacy unmarshalJSON helper | ||
| // (which exists in the v3 codepath but has no special semantics worth | ||
| // sharing). | ||
| func jsonUnmarshalLite(data []byte, v any) error { |
There was a problem hiding this comment.
jsonUnmarshalLite is a one-liner wrapper around json.Unmarshal that adds no behaviour and is only called once (in v1FetchBlockType). Worth replacing the call site with json.Unmarshal directly and removing this.
There was a problem hiding this comment.
Removed in 785138e. v1FetchBlockType now calls json.Unmarshal directly; encoding/json moved into v1_blocks.go and out of v1_other.go.
- v1_data_sources.go: don't swallow *mcp.RetryableError on the data_sources → databases fallback (rate-limit / 5xx should propagate so the runtime backs off; fallback only on non-retryable 4xx). Applied to update, query, and fetch helpers. - v1_pages.go: remove dead v1TitleKeyForParent (both branches returned "title"); inline the constant where it's used. - v1_other.go: drop jsonUnmarshalLite one-line wrapper, call json.Unmarshal directly from v1_blocks.go. - v1_test.go: add TestDispatchV1_EveryToolHasHandler and TestDispatchV1_NoOrphanHandlers — parity with v3 dispatch tests so a misspelled handler or a new tool added to Tools() without a v1 implementation fails at test time, not as a runtime "not yet supported on v1 backend" error.
acmacalister
left a comment
There was a problem hiding this comment.
Solid PR — build passes, tests pass, lint clean. The retryable-error guards and dispatch parity tests are both in good shape. Three things inline, two of which are about test coverage.
| // v1 payload with the block type as the top-level key (e.g. | ||
| // {"paragraph": {"rich_text": [...]}}) — that pass-through preserves the | ||
| // full v1 expressiveness (annotations, links, mentions, etc). | ||
| func v1UpdateBlock(ctx context.Context, n *notionV1, args map[string]any) (*mcp.ToolResult, error) { |
There was a problem hiding this comment.
v1UpdateBlock is the most complex handler in this PR — it makes an extra GET /blocks/{id} to discover the block type, then translates the v3 properties.title shape to a v1 rich_text payload. The passedThrough detection (skip "properties"/"format", treat anything else as a v1 payload) has branching that's easy to accidentally break.
Every other non-trivial handler has at least one httptest case; this one doesn't. The dispatch parity tests confirm it's wired up, but they don't exercise any of the translation logic. Worth adding cases for at least the v3 legacy path (where v1FetchBlockType is called) and the v1 pass-through path.
| body["page_size"] = pageSize | ||
| } | ||
|
|
||
| data, err := n.post(ctx, "/data_sources/"+id+"/query", body) |
There was a problem hiding this comment.
The errors.As guard here is the right fix, but there's no test proving a 429 from /data_sources/ actually stops the fallback. TestV1_QueryDataSourceFallsBackToDatabases only exercises the 404 path — if the guard is ever removed, the suite stays green.
Something like:
func TestV1_QueryDataSourceDoesNotFallBackOn429(t *testing.T) {
callCount := 0
v1, _ := newV1WithServer(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Retry-After", "5")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"object":"error","code":"rate_limited","message":"slow down"}`))
})
_, err := v1QueryDataSource(context.Background(), v1, map[string]any{"data_source_id": "ds-1"})
require.Error(t, err)
var re *mcp.RetryableError
require.ErrorAs(t, err, &re)
assert.Equal(t, 1, callCount, "429 should not trigger the /databases/ fallback")
}Same test is worth having for v1UpdateDataSource and v1FetchDatabaseOrDataSource (used by v1RetrieveDataSource).
| if err != nil { | ||
| return mcp.ErrResult(err) | ||
| } | ||
| if len(children) > 100 { |
There was a problem hiding this comment.
The > 100 guard fires after v1BuildPageBody has already normalized all N children and constructed the full body. No API call is made so the behavior is correct, but for a 200-child input we're doing twice the normalization work before surfacing the error. Moving the check before the v1BuildPageBody call (or even right after the normalization loop) keeps validation upfront:
if len(childrenAny) > 100 {
return mcp.ErrResult(fmt.Errorf("v1 create_page_with_content accepts up to 100 children..."))
}
children := make([]map[string]any, 0, len(childrenAny))
...
body, err := v1BuildPageBody(...)
arjansingh
left a comment
There was a problem hiding this comment.
Existing code looks fine. it's worth adding compact.yml specs because IIRC, even the v1 api has some extraneous info.
Per Arjan's review: v1 responses carry the same noise as v3 (full
created_by / last_edited_by user objects, cover/icon URLs, public_url,
is_archived/is_locked alongside in_trash, request_status wrappers, etc.)
but on a completely different shape, so compact.yaml's v3 paths don't
match.
compact_v1.yaml is a parallel spec file targeting the public-API JSON:
parent objects with nested type+id, properties as a map of typed values
keyed by user-named column names, has_more / next_cursor pagination,
data_sources list on databases (2025-09-03 split). Same per-tool schema
and same loader as compact.yaml — just different field paths.
Drops universally: object/type wrappers, created_by/last_edited_by full
user objects, cover, icon, public_url, request_status, is_archived/
is_locked duplicates, deprecated `archived` alias. Keeps id, parent,
properties, url, rich_text.
Multi-view tools (search, query_data_source, retrieve_data_source,
get_page_content, retrieve_comments) get the same slim-default-+-full
shape as v3. Views are JSON-only on v1 — the existing markdown
renderers are v3 record-map shaped and would mis-render v1 blocks; a
v1 markdown renderer is a future PR.
Wired via:
- //go:embed compact_v1.yaml in notion.go
- second compact.MustLoadWithOverlay call → v1FieldCompactionSpecs,
v1MaxBytesByTool, v1ViewSets
- CompactSpec / MaxBytes / Views switch on n.v1 != nil
Tests:
- TestV1FieldCompactionSpecs_AllParse / NoDuplicateTools /
NoOrphanSpecs / NoMutationTools — mirror the existing v3 structural
suite.
- TestV1FieldCompactionSpecs_ParityWithV3 — every v3 spec has a v1
counterpart and vice versa, so a tool added to one file but not the
other fails at test time.
- TestCompactSpec_SwitchesOnBackend / TestViews_SwitchesOnBackend —
configure with access_token, assert CompactSpec / Views return the
v1 map (the two are guaranteed-distinct because the field paths are).
|
Done in 4eb2ba7 — added You were right about the noise — drops Backed by a One known gap: views are JSON-only on v1 for now — the existing markdown renderers are v3 record-map shaped and would mis-render v1 blocks. A v1 markdown renderer is worth a follow-up PR but felt out of scope here. |
acmacalister
left a comment
There was a problem hiding this comment.
Build clean, full test suite green (race detector), lint at 0. The dual-backend design is well-executed and the parity/compaction tests are solid additions.
Two items from acmacalister's fixup review are still outstanding before merge: the missing 429-stops-fallback tests for v1QueryDataSource, v1UpdateDataSource, and v1FetchDatabaseOrDataSource (discussion_r3335291076), and the children > 100 guard placement in v1CreatePageWithContent (discussion_r3335291085). Both are real — I agree with that feedback. Nothing new blocking from this pass.
Summary
The current notion integration talks to
www.notion.so/api/v3with a browser session cookie (token_v2). That path still works for users who set it up before OAuth existed, but it's brittle (cookies expire, Notion can change v3 without notice) and unusable for hosted SaaS.This adds a parallel
notionV1backend that uses Notion's documented public REST API (api.notion.com/v1) with an OAuth bearer token and the date-stampedNotion-Versionheader. I picked2025-09-03— that's the version that introduced thedata_sourcesmodel, so the public surface matches the v3 backend's database/data-source split naturally and we don't need a translation layer.Backend selection happens in
Configure()based on which credential key is set:access_token→ v1 (OAuth, hosted-friendly)token_v2→ v3 (cookie, self-host legacy)If both are present, v1 wins.
Execute()andHealthy()delegate to v1 when it's configured; otherwise the existing v3 path runs unchanged. No behavior change for existingtoken_v2users.All tools the v3 backend exposes have a v1 equivalent (data sources, pages, blocks, search, users, comments, plus the convenience helpers
notion_get_page_content/notion_create_page_with_content). Routing lives indispatchV1. If a tool ends up v3-only in the future,Executereturns a clear "not supported on the Notion OAuth (v1) backend" error rather than a generic unknown-tool error.Test plan
go test ./integrations/notion/...— newv1_test.gocovers the configure handshake (auth + version headers), and each handler family with httptest servers asserting URL paths, request bodies, and response shaping.go test ./...— full suite green.golangci-lint run ./integrations/notion/...— clean.