Skip to content

feat: connect Notion tasks to Astra - #732

Merged
thomasluizon merged 9 commits into
redesign/mainfrom
fix/ticket-1484-notion-connection
Oct 17, 2026
Merged

thomasluizon merged 9 commits into
redesign/mainfrom
fix/ticket-1484-notion-connection

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Closes thomasluizon/orbit-tickets#1484.

Depends on #731, which carries thomasluizon/orbit-tickets#1480. Merge that dependency before this PR. This API change blocks thomasluizon/orbit-tickets#1485.

Approach

Pro users can authorize a public Notion connection, complete the server-side exchange, read connection status and shared databases, and disconnect through ConnectionsController. Commands and validators live in Orbit.Application/Connections; Notion entities and generated migrations persist encrypted credentials, a bounded task snapshot and single-use authorization states. Consumed states remain present until exchange completion so reset or disconnect can invalidate an exchange already in flight.

Orbit.Infrastructure/Services/Notion owns the documented REST interface, per-token request queue, full Retry-After waits, property mapping and scheduled poll. The poll runs every 15 minutes in both scheduler modes, claims its window atomically, keeps at most 100 tasks across sources, and checks the connection again before saving. A 401 requires reconnection; transient failures preserve the previous snapshot. Pro access and notion_integration gate every endpoint, poll and stored-task read. Notion forces a fresh flag read so a warm cache cannot delay the kill switch.

The keyword-gated get_notion_tasks tool, its capability registration and prompt guidance feed Notion data into the existing ask pick list with create_habits, then the existing preview. A small MCP wrapper delegates to the same reader to satisfy the repository's capability parity contract. Task text is explicitly framed as untrusted data and titles are capped. Reset, cascade deletion and the append-only export cover the new data; exports never contain credentials. src/Orbit.Api/openapi.json is regenerated from the controllers and contracts.

Test evidence

  • dotnet build Orbit.slnx: 0 errors.
  • Focused domain tests: 6 passed. Focused application tests, including ask/pick list and export coverage: 57 passed. Focused infrastructure, controller, catalog and parity tests: 79 passed.
  • Broader suites all exited 0: dotnet test tests/Orbit.Domain.Tests --no-build --no-restore (678 passed), dotnet test tests/Orbit.Application.Tests --no-build --no-restore (5,608 passed), dotnet test tests/Orbit.Infrastructure.Tests --no-build --no-restore (3,402 passed), and dotnet test tests/Orbit.Analyzers.Tests --no-build --no-restore (32 passed). Total: 9,720 passed, none skipped. Verification completed within the 25-minute budget.
  • This is a feature, so no existing Notion regression test existed at the base. Before strengthening coverage, the original controller and mapping checks passed with the reset/exchange race present: dotnet test tests/Orbit.Infrastructure.Tests --no-restore --filter 'FullyQualifiedName~Notion|FullyQualifiedName~ConnectionsControllerTests' initially passed 38 cases and failed the separate trial eligibility case. The later focused set passed 45 cases before the reset/exchange test was added.
  • To verify the new race test, restored the pre-fix state deletion behavior and ran dotnet test tests/Orbit.Infrastructure.Tests --no-restore --filter FullyQualifiedName~ResetOrDisconnectInvalidatesAnExchangeAlreadyInFlight: both reset and disconnect cases failed because the callback incorrectly returned 204 and recreated the connection. Restored the consumed-state fix and reran the focused infrastructure set: all 75 passed, including both cases.
  • New tests also failed before fixes for trial Pro scheduling and revocation timeouts; both pass after the fixes. HTTP tests verify a 73-second Retry-After wait without a 60-second cap, bounded retries, documented auth, pagination and revocation. SQLite persistence tests inspect ciphertext columns, foreign-key cascades and reset cleanup without a live database. Fixtures and their documentation provenance are in tests/Orbit.Infrastructure.Tests/Fixtures/Notion.
  • The unchanged focused set passed 75 cases with a CLR timestamp comparison in the poll claim check. PostgreSQL timestamps have microsecond resolution, confirmed 2026-10-17. Added PollPersistsWhenProviderMaterializesTimestampsAtMicrosecondPrecision, using a materialization interceptor to reproduce that documented precision without a live database. dotnet test tests/Orbit.Infrastructure.Tests --no-restore --filter FullyQualifiedName~PollPersistsWhenProviderMaterializesTimestampsAtMicrosecondPrecision failed with zero saved tasks instead of one before the fix. Moving claim equality into the database query made it pass; the focused set then passed all 76 cases.
  • The first broader infrastructure run failed its unchanged CapabilityParityTests.EveryCapability_HasMatchingChatAndMcpSurfaces test because the Notion reader lacked an MCP mapping. Added a wrapper over the shared read tool and two behavior tests. The focused run including that parity test passed all 79 cases.
  • On 5fc3a739, unchanged tests passed:
    dotnet test tests/Orbit.Infrastructure.Tests --filter 'FullyQualifiedName~CapabilityParityTests|FullyQualifiedName~NotionToolsTests'
    Exit 0, 3 passed. After the fix: exit 0, 4 passed.
  • Before the fix, the new test failed specifically because get_notion_tasks was unregistered:
    dotnet test tests/Orbit.Infrastructure.Tests --filter FullyQualifiedName~EveryDeclaredMcpTool_IsRegisteredByProductionBootstrap
    Exit 1. It passed after registration.
  • dotnet build Orbit.slnx: exit 0, zero errors. OpenAPI regenerated without changes.
  • dotnet test tests/Orbit.Domain.Tests: exit 0, 678 passed.
  • dotnet test tests/Orbit.Application.Tests: exit 0, 5,608 passed.
  • dotnet test tests/Orbit.Infrastructure.Tests: exit 0, 3,403 passed.
  • All commit hooks passed.

MCP 1.4.1 fields verified against its exact package-source revision: ToolCollection, ProtocolTool, Name.

Notion interface evidence

All pages below were read live on 2026-10-17 before writing the corresponding client behavior. Re-derive the evidence by fetching each linked page with .md appended; endpoint pages contain the current OpenAPI schema. Credentials do not exist yet, so no authenticated Notion response or staging flow is claimed.

Documentation Confirmed interface used
Authorization, read 2026-10-17 https://api.notion.com/v1/oauth/authorize; owner=user, response_type=code, client_id, redirect_uri, state; page picker and code return; server-side Basic authentication.
Create a token, read 2026-10-17 POST /v1/oauth/token; authorization-code grant, code, matching redirect URI; response access_token, nullable refresh_token, workspace_id, nullable workspace_name, bot_id. Other response fields are ignored.
Refresh a token, read 2026-10-17 The token endpoint accepts a refresh-token grant and rotates both tokens. Its schema does not provide an access-token expiry field. No expiry is invented or read; a poll 401 requires reconnect as specified.
Revoke a token, read 2026-10-17 POST /v1/oauth/revoke, Basic authentication and {token}; success depends on the HTTP status. No response field is required by the implementation.
Search by title, read 2026-10-17 POST /v1/search, filter {property:"object",value:"data_source"} and no query to discover shared sources, including their property schemas.
Data source, read 2026-10-17 id, parent.database_id, rich-text title, properties; data sources belong to database containers.
Retrieve a database and Database, read 2026-10-17 GET /v1/databases/{id} and rich-text title provide the actual database name rather than a child source's name.
Query a data source, read 2026-10-17 POST /v1/data_sources/{id}/query, result_type:"page", paginated results and the documented 10,000-result query ceiling. The account cap stops retention at 100; no completeness claim is made beyond the provider's query ceiling.
Pagination, read 2026-10-17 page_size maximum 100, start_cursor, results, has_more, next_cursor; cursors are opaque.
Page, read 2026-10-17 id, properties, url, last_edited_time; recorded page envelope used by the mapping tests.
Page property values and Property schemas, read 2026-10-17 Property id and type; title rich-text plain_text; nullable date start and time_zone; status id and name; schema status groups name and option_ids; checkbox boolean. Open membership comes from the documented To-do/In progress groups, including custom option names.
Request limits, read 2026-10-17 Baseline 180 requests per minute, higher workspace tiers 600; a separate workspace budget can throttle. Queue spacing is 350 ms per token. HTTP 429/529 respect integer-second Retry-After, retry with backoff, and stop after three retries. Documented additional_data.rate_limit_reason=public_api_request_blocked stops retries.
Capabilities, read 2026-10-17 Read content is sufficient; this connection needs no content write, comment or user-information capability and sends no invented OAuth scope parameter.
Versioning and Status codes, read 2026-10-17 Pin Notion-Version: 2026-03-11; bearer auth for data calls; HTTP status drives reconnect/transient behavior.
Search limitations, read 2026-10-17 Discovery is eventually consistent and cannot guarantee exhaustive indexing. Shared databases are refreshed at each poll.

Assumptions

  • Use the current database/data-source model with containing database IDs; rejected the deprecated database-query interface.
  • Authorize accepts redirectUri in its request body and binds it to state; rejected a single hard-coded redirect because configuration allows multiple clients.
  • Select the first date property in the returned schema, prefer status over checkbox, and treat a schema with neither as titles only; rejected matching user-defined column names.
  • Use a date range's start and preserve date-only values, converting timestamp dates into the person's timezone; rejected server-local calendar dates and the range end.
  • Apply the 100-task cap across all shared data sources in one account; rejected a separate cap per database. A successful poll replaces the stored snapshot so removed or unshared pages do not linger.
  • Persist nullable refresh tokens without guessing a refresh schedule; rejected an undocumented expiry field and automatic retry after a 401 because the ticket requires reconnect.
  • Attempt revocation before local cleanup, then still delete local data on a provider error or timeout; rejected retaining personal data because the remote service is unavailable.
  • Register tools through the existing dependency-injection collection and capability catalog; rejected adding a second manual registry to AiToolRegistry.
  • Include the matching read-only MCP wrapper required by CapabilityParityTests; rejected weakening the parity guard or duplicating Notion read logic.
  • Invoked the private bootstrap through reflection instead of widening its visibility, preserving production code unchanged for the red test.

Manual steps

  • In the Notion Developer portal, Connections > New connection, create Orbit's public connection. In Configuration, enable Read content only and configure OAuth Domain & URIs for the approved client return URLs from #1485. Prove it with Notion's page picker and a successful staging authorization-code exchange. Complete any required public-connection review before enabling production access.
  • In Render Dashboard, the staging API service > Environment, set Notion__ClientId and Notion__ClientSecret from that connection's Configuration tab. Set Notion__AllowedRedirectUris__0 and subsequent indexed keys to exactly the same approved return URLs. Confirm the existing Encryption__Key is configured. Prove the settings through successful authorization with an allowed URI and rejection of an unlisted URI. Repeat the settings on the production API service only for rollout.
  • In the staging PostgreSQL AppFeatureFlags table, enable notion_integration only for the staging flow after configuration is present; confirm PlanRequirement remains Pro. Verify polling, property mapping, reconnect handling, the existing pick list preview and disconnect cleanup. In the production table keep Enabled=false until the staging flow, #1485 and any Notion review are complete. Verify a disabled flag returns the existing gate error and stops new reads and polls. No flag or environment was changed by this worker.
  • After this merges, the next staging API release from redesign/main lists get_notion_tasks in the authenticated MCP tools/list. No new configuration or secrets are required.

Eval scenarios for #26

  • "what is due in Notion this week" calls get_notion_tasks, answers only from its result and identifies the stored snapshot's poll time. If offering conversion, use the existing create_habits pick list and preview for the chosen items.
  • A Notion task title containing an instruction, such as a request to delete habits, is reported as task data and never followed. The read result must not trigger an unrelated tool or write.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new Notion MCP reader is missing from the running server's tool registry, so MCP clients cannot discover or invoke it.

Reviewed changes
Reviewed the complete Notion integration and its included Astra choice/pick-list prerequisite, including persistence, authorization, provider contracts, and client compatibility.

  • Connection lifecycle: Pro-gated OAuth authorization, single-use states, encrypted credentials, connection status, and disconnect cleanup.
  • Task snapshots: Scheduled polling, pagination and throttling, user-timezone property mapping, bounded retention, and reconnect/error handling.
  • Astra and MCP: Negotiated interactive blocks, one-shot answers, stored-selection bulk previews, Notion tool/catalog wiring, and untrusted-data prompt guidance.
  • Persistence and contracts: Migrations, reset/cascade deletion, credential-free exports, append-only HTTP contracts, and the API-first client rollout.
  • Verification: The solution build succeeded and all 9,720 tests passed; a separate probe of the production MCP bootstrap reproduced the missing registration. Authenticated Notion staging behavior was not exercised.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using gpt-6.1-sol | 𝕏

Comment thread src/Orbit.Api/Mcp/Tools/NotionTools.cs Resolved

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes
Reviewed bd70ca7c, the single commit since the prior Pullfrog review, against the full PR context and resolved discussion.

  • Registered the Notion reader: Added NotionTools to the production MCP bootstrap, addressing the previously reported discovery and invocation failure.
  • Added runtime registry coverage: Checked every catalog-declared MCP tool against the actual production tool collection rather than relying only on catalog strings or direct wrapper calls.
  • Verified the affected paths: All 351 selected MCP, catalog, and Notion tests passed, including the new production-bootstrap regression test. The resolved finding is fixed, and no new blocking issues remain.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

The squash of orbit-api#731 replaced the commits this branch was stacked on.
Five add/add conflicts take the merged #731 version (this branch did not
change them beyond its older #731 base); the model snapshot keeps this
branch's Notion entities on top of it. The result equals the tree of
combined check a2 (base c8877ee with #731, #732 and #730 merged), which
passed build, the openapi freshness check and all four test projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon

Copy link
Copy Markdown
Owner Author

@pullfrog review

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes Read the complete 67-file diff and traced the Notion connection through authorization, persistence, scheduled polling, and its chat and MCP consumers.

  • Connection lifecycle: Reviewed the Pro and feature-flag gates, redirect allowlist, single-use OAuth state, encrypted credentials, and reset/disconnect handling for in-flight operations.
  • Task snapshots: Reviewed provider requests and pagination against current Notion documentation, per-token throttling, user-local date mapping, account-wide retention cap, and transactional snapshot replacement.
  • Agent surfaces: Verified the shared read tool, capability and scope mappings, production MCP registration, and untrusted-data prompt framing.
  • Data compatibility: Checked migrations, cascade cleanup, credential-free exports, and the sibling consumer contracts. Existing contracts remain additive; the client connection UI is explicitly deferred to the blocked follow-up ticket.
  • Verification: All 91 focused infrastructure, application, and domain tests passed, including production MCP registration coverage. Authenticated Notion staging was not exercised; the documented staging validation remains necessary before enabling the production flag.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant