@zackbart/connecta 0.30.0 → 0.32.0

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 (133) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +21 -16
  3. package/bin/connecta.mjs +1 -0
  4. package/bin/version.mjs +1 -1
  5. package/dist/auth-elicitation.js +1 -1
  6. package/dist/call-recovery.js +25 -0
  7. package/dist/catalog-service.js +7 -2
  8. package/dist/config-schema.d.ts +14 -7
  9. package/dist/config-schema.js +29 -10
  10. package/dist/config-value-sources.js +6 -3
  11. package/dist/config.d.ts +26 -8
  12. package/dist/config.js +38 -16
  13. package/dist/connector-access.d.ts +2 -1
  14. package/dist/connectors/guarded-fetch.d.ts +18 -1
  15. package/dist/connectors/guarded-fetch.js +21 -2
  16. package/dist/describe-config.d.ts +6 -3
  17. package/dist/describe-config.js +3 -2
  18. package/dist/errors.d.ts +3 -1
  19. package/dist/errors.js +1 -0
  20. package/dist/execute.js +16 -14
  21. package/dist/index.js +4 -3
  22. package/dist/invocation.js +39 -4
  23. package/dist/meta-tools.js +17 -19
  24. package/dist/operator-record.d.ts +2 -2
  25. package/dist/operator-record.js +2 -0
  26. package/dist/operator-ui/contract.d.ts +5 -4
  27. package/dist/operator-ui/generated.js +2 -2
  28. package/dist/operator-ui/model.d.ts +1 -1
  29. package/dist/operator-ui/view.d.ts +9 -0
  30. package/dist/operator-ui/view.js +12 -0
  31. package/dist/program-writes.js +4 -49
  32. package/dist/provider.d.ts +6 -5
  33. package/dist/provider.js +3 -4
  34. package/dist/providers/_shared/google/workspace.js +1 -1
  35. package/dist/providers/_shared/rest/dispatch.js +41 -0
  36. package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
  37. package/dist/providers/_shared/rest/operation-index.js +527 -0
  38. package/dist/providers/_shared/rest/tools.d.ts +141 -0
  39. package/dist/providers/_shared/rest/tools.js +569 -0
  40. package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
  41. package/dist/providers/_shared/rest/value-safety.js +580 -0
  42. package/dist/providers/breeze/skill.generated.js +2 -2
  43. package/dist/providers/cloudflare/graphql.js +385 -0
  44. package/dist/providers/cloudflare/index.d.ts +47 -51
  45. package/dist/providers/cloudflare/index.js +188 -1997
  46. package/dist/providers/cloudflare/openapi.generated.js +3347 -0
  47. package/dist/providers/cloudflare/rest.d.ts +37 -0
  48. package/dist/providers/cloudflare/rest.js +1056 -0
  49. package/dist/providers/cloudflare/skill.generated.js +7 -4
  50. package/dist/providers/cloudflare/value-safety.js +1247 -0
  51. package/dist/providers/docs/index.d.ts +1 -1
  52. package/dist/providers/docs/index.js +2 -2
  53. package/dist/providers/drive/index.d.ts +1 -1
  54. package/dist/providers/drive/index.js +1 -1
  55. package/dist/providers/forms/index.d.ts +1 -1
  56. package/dist/providers/forms/index.js +2 -2
  57. package/dist/providers/github/skill.generated.js +2 -2
  58. package/dist/providers/gmail/index.d.ts +1 -1
  59. package/dist/providers/gmail/index.js +274 -23
  60. package/dist/providers/gmail/skill.generated.js +2 -2
  61. package/dist/providers/linear/index.js +1 -1
  62. package/dist/providers/mixpanel/index.d.ts +27 -12
  63. package/dist/providers/mixpanel/index.js +58 -6
  64. package/dist/providers/mixpanel/operations.js +20 -0
  65. package/dist/providers/mixpanel/rest.js +603 -0
  66. package/dist/providers/mixpanel/skill.generated.js +3 -2
  67. package/dist/providers/mixpanel/value-safety.js +16 -0
  68. package/dist/providers/notion/index.d.ts +35 -31
  69. package/dist/providers/notion/index.js +76 -1499
  70. package/dist/providers/notion/openapi.generated.js +75 -0
  71. package/dist/providers/notion/rest.d.ts +24 -0
  72. package/dist/providers/notion/rest.js +1186 -0
  73. package/dist/providers/notion/skill.generated.js +5 -4
  74. package/dist/providers/notion/value-safety.js +31 -0
  75. package/dist/providers/planning-center/index.d.ts +27 -0
  76. package/dist/providers/planning-center/index.js +286 -24
  77. package/dist/providers/planning-center/skill.generated.js +24 -4
  78. package/dist/providers/sheets/index.d.ts +1 -1
  79. package/dist/providers/sheets/index.js +3 -3
  80. package/dist/providers/slides/index.d.ts +1 -1
  81. package/dist/providers/slides/index.js +3 -3
  82. package/dist/providers/stripe/index.d.ts +26 -20
  83. package/dist/providers/stripe/index.js +103 -130
  84. package/dist/providers/stripe/openapi.generated.js +649 -0
  85. package/dist/providers/stripe/rest.d.ts +14 -0
  86. package/dist/providers/stripe/rest.js +395 -0
  87. package/dist/providers/stripe/skill.generated.js +7 -5
  88. package/dist/providers/stripe/value-safety.js +705 -0
  89. package/dist/providers/vercel/index.d.ts +31 -22
  90. package/dist/providers/vercel/index.js +96 -853
  91. package/dist/providers/vercel/openapi.generated.js +456 -0
  92. package/dist/providers/vercel/rest.d.ts +20 -0
  93. package/dist/providers/vercel/rest.js +1002 -0
  94. package/dist/providers/vercel/skill.generated.js +4 -5
  95. package/dist/providers/vercel/value-safety.js +328 -0
  96. package/dist/providers/wispr-flow/index.d.ts +14 -0
  97. package/dist/providers/wispr-flow/index.js +117 -0
  98. package/dist/providers/wispr-flow/skill.generated.js +8 -0
  99. package/dist/routes/mcp.js +30 -10
  100. package/dist/routes/operator.js +14 -7
  101. package/dist/routes/ui-config.js +2 -2
  102. package/dist/server.js +3 -0
  103. package/dist/skills.js +5 -4
  104. package/dist/tool-safety.js +13 -5
  105. package/dist/types.d.ts +1 -1
  106. package/dist/ui.d.ts +1 -1
  107. package/dist/ui.js +1 -1
  108. package/dist/usage-guide.js +3 -3
  109. package/dist/version.d.ts +1 -1
  110. package/dist/version.js +1 -1
  111. package/dist/write-outcome.js +47 -0
  112. package/documentation/README.md +1 -0
  113. package/documentation/architecture.md +155 -28
  114. package/documentation/auth.md +86 -27
  115. package/documentation/code-mode.md +81 -72
  116. package/documentation/deploying.md +10 -5
  117. package/documentation/integrating.md +72 -9
  118. package/documentation/meta-tools.md +52 -38
  119. package/documentation/operating.md +11 -8
  120. package/documentation/operator-tests.md +2 -3
  121. package/documentation/operator-ui.md +9 -3
  122. package/documentation/provider-auth-migration.md +308 -0
  123. package/documentation/provider-migration-0.29.md +18 -6
  124. package/examples/worker/AGENTS.md +5 -0
  125. package/examples/worker/README.md +2 -2
  126. package/examples/worker/src/connecta.config.ts +5 -0
  127. package/package.json +7 -2
  128. package/templates/node/AGENTS.md +6 -4
  129. package/templates/node/README.md +6 -4
  130. package/templates/node/package.json +1 -1
  131. package/templates/node/src/connecta.config.ts +5 -0
  132. package/dist/providers/vercel/mcp-ownership.js +0 -212
  133. package/dist/tool-safety.d.ts +0 -9
@@ -1,10 +1,11 @@
1
1
  // Generated by scripts/generate-providers.mjs; do not edit.
2
2
  export const skill = {
3
- "content": "\n## Identity before routing\n\nThis explicit `surface: \"api\"` connection acts as an internal integration.\nIts `integration_*` tools are canonical for bot-scoped/headless operations.\nHosted MCP is the default and acts as its OAuth-authorized user. Authorization\nis not equivalent: an integration token cannot authorize hosted MCP, and user\nOAuth cannot reproduce the integration bot's ownership/sharing boundary.\nConfigure each under a separate connector id and authorize it independently.\nDo not switch to a user identity to recover a failed integration operation.\n\nUse the bot tools when the task requires that integration identity. Use hosted\n`notion-search`, `notion-fetch`, `notion-get-users`, `notion-get-comments`,\n`notion-create-pages`, `notion-update-page`, and `notion-create-comment` for\nuser-owned content work. Hosted MCP additionally covers sessions, agents,\nattachments and broader workspace operations. REST tool names are not MCP aliases.\n\n\n\n## Databases contain data sources\n\nA Notion database is a container; the rows and the schema live in a *data\nsource* inside it. The two ids are not interchangeable and Notion rejects the\nwrong one.\n\n- The id in a Notion database URL is a **database id**.\n- `integration_get_database` turns it into the `data_sources` list — usually one entry.\n- `integration_get_data_source_schema` and `integration_query_data_source` take that\n **data_source_id**, and so does `integration_create_page` when adding a row.\n\nSo the sequence for \"find rows in this database\" is `integration_get_database` →\n`integration_get_data_source_schema` → `integration_query_data_source`. `integration_search` returns data\nsources directly and skips the first step.\n\n## Property quirks that break writes\n\n- Property names in filters, sorts, and writes must match the schema exactly,\n including case. Read `integration_get_data_source_schema` before composing one.\n- `select` and `status` writes must use an existing option name; inventing\n one fails. The schema lists the valid options.\n- Writes **replace** a property. Sending one item to a `multi_select` or\n `relation` drops the others, so send the complete intended value.\n- `rollup`, `formula`, `unique_id`, and the created/edited fields are\n computed. They cannot be written.\n- A page's title column is rarely called \"title\" — pass `title_property` from\n the schema when creating or updating a row.\n- Notion truncates `title`, `rich_text`, `relation`, and `people` at 25\n entries. `integration_get_page` reports each one in `truncated_properties` as\n `{ name, id }`; pass that `id` as `property_id` to\n `integration_get_page_property` for the complete value.\n\n## Reading page content\n\n`integration_get_page` returns properties. `integration_get_page_content` returns the body as flat\nblocks. Nested blocks (toggles, list children, table rows) need `depth`, and\neach level multiplies requests — a deep read stops at an internal ceiling and\nreports `truncated: true` rather than spending the whole rate-limit budget.\n\n## Appending is append-only\n\n`integration_append_blocks` adds children and nothing else. It cannot move, reorder, or\nreplace an existing block, and a block appended through the API can never be\nrelocated by it afterwards. Get the position right the first time with\n`position` and `after_block_id`. Notion caps one call at 100 blocks.\n\n## Lean by default, raw on request\n\nEvery read projects Notion's payload down to ids, plain text, and flattened\nproperty values. Where the dropped detail can matter — `integration_search`, `integration_get_page`,\n`integration_get_page_content`, `integration_get_page_property`, `integration_get_data_source_schema`,\n`integration_query_data_source`, `integration_list_comments` — pass `raw: true` to get Notion's\nuntouched response instead. It is much larger; reach for it only when a\nspecific field is missing. Narrow `integration_query_data_source` and `integration_get_page` with\n`properties` instead whenever the goal is fewer fields, not more.\n\n`integration_get_page_content` with `raw: true` returns one level exactly as Notion\nsent it and does not walk nested children, so `depth` is ignored alongside\nit. A block type this projection does not model keeps its payload under\n`raw` on the block itself, so nothing silently flattens to an empty string.\n\n## Failures worth reading carefully\n\n- **404** means \"no such object\" *or* \"not shared with this integration\", and\n Notion will not say which. Never conclude a page was deleted from it; check\n that the page is shared with the integration in Notion.\n- **403** is not an expired token. The integration is missing a capability\n (comment capabilities are off by default) or the object was never shared.\n Re-authorizing cannot fix it; an operator must change it in Notion.\n- **429** carries a retry window. Notion allows roughly three requests per\n second per integration, so wait it out rather than retrying immediately.\n\n## No escape hatch\n\nThis connection has no guarded raw-REST tool, deliberately. Notion's public\nAPI is small and slow-moving enough for the named surface to cover it, so\nthere is no `notion_api_*` to reach for — an operation absent from the tool\nlist is absent from this connection, not hidden behind a generic call.\n\n## Writes and pagination\n\n- Notion has **no idempotency key**. A retried `integration_create_page` or\n `integration_add_comment` creates a duplicate. Confirm with `integration_search` before repeating\n a write that may have partially succeeded.\n- List tools take `page_size` (max 100) and return `has_more` with\n `next_cursor`. Pass a cursor back verbatim — it is opaque and must never be\n parsed or constructed. Follow pages inside `execute_code` and reduce there.\n# Notion MCP usage\n\nOfficial MCP interface: tool names, descriptions, argument schemas, and result\nschemas come from Notion's live server. Connecta preserves that catalog and\napplies release-reviewed classification. Reviewed writes stay writes even when Notion claims they only read.\n\nWorkspace purpose: \n\n- This connection acts as its OAuth-authorized user. The separately configured\n `surface: \"api\"` connection retains required internal-integration operations\n under `integration_*` names, including headless content writes, cursor-complete\n queries, exact JSON blocks and property items. No identity fallback occurs.\n- Discover the live catalog before assuming a tool exists. Notion can gate\n tools by workspace, account, client, and rollout independently of Connecta.\n- Start broad discovery with `notion-search`, then use `notion-fetch` on\n the exact page, database, data source, or object before changing it.\n- Use the live input schema as the contract. Notion owns these MCP schemas;\n the REST schemas in Connecta's API interface do not apply to MCP tools with\n similar names.\n- Session and agent tools can launch asynchronous work. Read session state and\n events before sending another message, waiting, or stopping a session.\n- File uploads and attachment tools create durable workspace state. Keep\n downloads inside the requested task and do not expose signed attachment URLs.\n- An `auth_required` failure means this connector's OAuth grant is missing or\n expired. Run `authorize_connector` for this connector id, then retry.\n",
3
+ "content": "\n## Notion's data model\n\n- A database is a container; its rows and schema live in a *data source*\n inside it. Queries, schema reads, and new rows take the data source, and\n Notion rejects a database id in its place.\n- Property names in filters, sorts, and writes must match the schema exactly,\n including case. Read the schema before composing one.\n- A `status` write must use an existing option name; inventing one fails.\n A new `select` or `multi_select` name does not fail: when the connection\n can write to the data source, Notion adds it to the schema as a new\n option. Use the options the schema lists unless a schema change is meant.\n- Writes **replace** a property. Sending one item to a `multi_select` or\n `relation` drops the others, so send the complete intended value.\n- `rollup`, `formula`, `unique_id`, and the created and edited fields are\n computed. They cannot be written.\n- A data source's title column is rarely called \"title\". Read its name from\n the schema before creating or renaming a row.\n- Notion has **no idempotency key**. A retried page create or comment makes a\n duplicate. Confirm with a search before repeating a write that may have\n partially succeeded.\n",
4
4
  "instructionsHeading": "Workspace instructions",
5
5
  "fragments": {
6
- "guide_0": "\n## Identity before routing\n\nThis explicit `surface: \"api\"` connection acts as an internal integration.\nIts `integration_*` tools are canonical for bot-scoped/headless operations.\nHosted MCP is the default and acts as its OAuth-authorized user. Authorization\nis not equivalent: an integration token cannot authorize hosted MCP, and user\nOAuth cannot reproduce the integration bot's ownership/sharing boundary.\nConfigure each under a separate connector id and authorize it independently.\nDo not switch to a user identity to recover a failed integration operation.\n\nUse the bot tools when the task requires that integration identity. Use hosted\n`notion-search`, `notion-fetch`, `notion-get-users`, `notion-get-comments`,\n`notion-create-pages`, `notion-update-page`, and `notion-create-comment` for\nuser-owned content work. Hosted MCP additionally covers sessions, agents,\nattachments and broader workspace operations. REST tool names are not MCP aliases.\n\n\n\n## Databases contain data sources\n\nA Notion database is a container; the rows and the schema live in a *data\nsource* inside it. The two ids are not interchangeable and Notion rejects the\nwrong one.\n\n- The id in a Notion database URL is a **database id**.\n- `integration_get_database` turns it into the `data_sources` list — usually one entry.\n- `integration_get_data_source_schema` and `integration_query_data_source` take that\n **data_source_id**, and so does `integration_create_page` when adding a row.\n\nSo the sequence for \"find rows in this database\" is `integration_get_database` →\n`integration_get_data_source_schema` → `integration_query_data_source`. `integration_search` returns data\nsources directly and skips the first step.\n\n## Property quirks that break writes\n\n- Property names in filters, sorts, and writes must match the schema exactly,\n including case. Read `integration_get_data_source_schema` before composing one.\n- `select` and `status` writes must use an existing option name; inventing\n one fails. The schema lists the valid options.\n- Writes **replace** a property. Sending one item to a `multi_select` or\n `relation` drops the others, so send the complete intended value.\n- `rollup`, `formula`, `unique_id`, and the created/edited fields are\n computed. They cannot be written.\n- A page's title column is rarely called \"title\" — pass `title_property` from\n the schema when creating or updating a row.\n- Notion truncates `title`, `rich_text`, `relation`, and `people` at 25\n entries. `integration_get_page` reports each one in `truncated_properties` as\n `{ name, id }`; pass that `id` as `property_id` to\n `integration_get_page_property` for the complete value.\n\n## Reading page content\n\n`integration_get_page` returns properties. `integration_get_page_content` returns the body as flat\nblocks. Nested blocks (toggles, list children, table rows) need `depth`, and\neach level multiplies requests — a deep read stops at an internal ceiling and\nreports `truncated: true` rather than spending the whole rate-limit budget.\n\n## Appending is append-only\n\n`integration_append_blocks` adds children and nothing else. It cannot move, reorder, or\nreplace an existing block, and a block appended through the API can never be\nrelocated by it afterwards. Get the position right the first time with\n`position` and `after_block_id`. Notion caps one call at 100 blocks.\n\n## Lean by default, raw on request\n\nEvery read projects Notion's payload down to ids, plain text, and flattened\nproperty values. Where the dropped detail can matter — `integration_search`, `integration_get_page`,\n`integration_get_page_content`, `integration_get_page_property`, `integration_get_data_source_schema`,\n`integration_query_data_source`, `integration_list_comments` — pass `raw: true` to get Notion's\nuntouched response instead. It is much larger; reach for it only when a\nspecific field is missing. Narrow `integration_query_data_source` and `integration_get_page` with\n`properties` instead whenever the goal is fewer fields, not more.\n\n`integration_get_page_content` with `raw: true` returns one level exactly as Notion\nsent it and does not walk nested children, so `depth` is ignored alongside\nit. A block type this projection does not model keeps its payload under\n`raw` on the block itself, so nothing silently flattens to an empty string.\n\n## Failures worth reading carefully\n\n- **404** means \"no such object\" *or* \"not shared with this integration\", and\n Notion will not say which. Never conclude a page was deleted from it; check\n that the page is shared with the integration in Notion.\n- **403** is not an expired token. The integration is missing a capability\n (comment capabilities are off by default) or the object was never shared.\n Re-authorizing cannot fix it; an operator must change it in Notion.\n- **429** carries a retry window. Notion allows roughly three requests per\n second per integration, so wait it out rather than retrying immediately.\n\n## No escape hatch\n\nThis connection has no guarded raw-REST tool, deliberately. Notion's public\nAPI is small and slow-moving enough for the named surface to cover it, so\nthere is no `notion_api_*` to reach for — an operation absent from the tool\nlist is absent from this connection, not hidden behind a generic call.\n\n## Writes and pagination\n\n- Notion has **no idempotency key**. A retried `integration_create_page` or\n `integration_add_comment` creates a duplicate. Confirm with `integration_search` before repeating\n a write that may have partially succeeded.\n- List tools take `page_size` (max 100) and return `has_more` with\n `next_cursor`. Pass a cursor back verbatim — it is opaque and must never be\n parsed or constructed. Follow pages inside `execute_code` and reduce there.\n",
7
- "guide_1": "# Notion MCP usage\n\nOfficial MCP interface: tool names, descriptions, argument schemas, and result\nschemas come from Notion's live server. Connecta preserves that catalog and\napplies release-reviewed classification. Reviewed writes stay writes even when Notion claims they only read.\n\nWorkspace purpose: ",
8
- "guide_2": "\n\n- This connection acts as its OAuth-authorized user. The separately configured\n `surface: \"api\"` connection retains required internal-integration operations\n under `integration_*` names, including headless content writes, cursor-complete\n queries, exact JSON blocks and property items. No identity fallback occurs.\n- Discover the live catalog before assuming a tool exists. Notion can gate\n tools by workspace, account, client, and rollout independently of Connecta.\n- Start broad discovery with `notion-search`, then use `notion-fetch` on\n the exact page, database, data source, or object before changing it.\n- Use the live input schema as the contract. Notion owns these MCP schemas;\n the REST schemas in Connecta's API interface do not apply to MCP tools with\n similar names.\n- Session and agent tools can launch asynchronous work. Read session state and\n events before sending another message, waiting, or stopping a session.\n- File uploads and attachment tools create durable workspace state. Keep\n downloads inside the requested task and do not expose signed attachment URLs.\n- An `auth_required` failure means this connector's OAuth grant is missing or\n expired. Run `authorize_connector` for this connector id, then retry.\n"
6
+ "oauth_head": "# Notion MCP usage\n\nOfficial MCP interface: tool names, descriptions, argument schemas, and result\nschemas come from Notion's live server. Connecta preserves that catalog and\napplies release-reviewed classification. Reviewed writes stay writes even when Notion claims they only read.\n\nWorkspace purpose: ",
7
+ "oauth": "\n\n- This connection acts as its OAuth-authorized user and reaches what that user\n can. It holds no integration token. An internal integration (bot) is a\n separate connector with `auth: { type: \"token\" }` under another id; never\n switch to it to recover a failed call here.\n- Discover the live catalog before assuming a tool exists. Notion can gate\n tools by workspace, account, client, and rollout independently of Connecta.\n `notion-get-tool-access` reports what the workspace's plan allows.\n- Start broad discovery with `notion-search`, then use `notion-fetch` on\n the exact page, database, data source, or object before changing it.\n- Use the live input schema as the contract. Notion owns these MCP schemas;\n the REST schemas of Connecta's token connector do not apply to MCP tools with\n similar names.\n- Session and agent tools can launch asynchronous work. Read session state and\n events before sending another message, waiting, or stopping a session.\n- File uploads and attachment tools create durable workspace state. Keep\n downloads inside the requested task and do not expose signed attachment URLs.\n `notion-download-skill` returns a signed, expiring archive URL by design;\n the token connector's REST redaction does not apply to hosted tools, so\n use that URL for the download and do not repeat it in replies.\n- Connecta's generic REST tools (`notion_api_*`) and the `integration_*`\n helpers exist only on a token connector.\n- An `auth_required` failure means this connector's OAuth grant is missing or\n expired. Run `authorize_connector` for this connector id, then retry.\n",
8
+ "key": "\n## Identity and tools\n\nThis is Connecta's REST connector to `api.notion.com`, acting as one internal\nintegration (a bot) with an operator-managed integration token. It reaches\nexactly the pages and data sources shared with that integration, and the\nintegration's capabilities decide which calls succeed: comment and\nuser-information capabilities are off by default. It never acts as a person.\nHosted Notion MCP is a separate OAuth connector.\n\n- Named tools cover the common path and flatten Notion's payloads:\n `integration_search` (titles), `integration_get_page` (properties),\n `integration_get_page_content` (blocks as text),\n `integration_get_data_source_schema`, and `integration_query_data_source`\n (rows). `integration_create_page` and `integration_append_blocks` author\n Notion's rich-text and block shapes from plain text, Markdown, or checklists.\n- Four generic tools reach every other operation in the pinned API index.\n `notion_api_search` finds operations, not content. `notion_api_details`\n returns one operation's parameters. `notion_api_read` calls GETs plus the\n read-only POST queries: search, data source and meeting note queries, and\n agent, session, and session event queries. `notion_api_write` calls every\n other POST, PATCH, and DELETE with `method` and `path` stated.\n- Every generic call is checked against the index before it is sent. An\n unknown path returns the nearest operations, and an unknown or missing\n parameter returns `validation.issues` with the accepted names. Nothing\n reached Notion, so fix the call from that answer.\n- The generic set replaces the old one-operation tools. Read a database\n container with `GET /v1/databases/{database_id}`, users with `GET /v1/users`\n or `GET /v1/users/me`, and comments with `GET /v1/comments`. Update\n properties, trash or restore (`in_trash`) with `PATCH /v1/pages/{page_id}`,\n and comment with `POST /v1/comments` (`rich_text` or `markdown`).\n- Refused by design: Notion's OAuth token endpoints (`/v1/oauth/*`) and\n multipart file sends. Import a file with `POST /v1/file_uploads` and\n `mode: \"external_url\"` instead.\n- Notion-hosted file URLs in pages and blocks are pre-signed: Connecta\n withholds their signature, credential, and security-token parameters, so a\n returned URL names the file but does not download it. File links inside\n page Markdown come back as Notion sent them: use one only for its download\n and do not repeat it in replies. Credential-named\n fields read as `\"[redacted]\"`, a database property named like a credential\n (`API token`) included. Failures of the OAuth, file upload, bot user, and\n agent operations carry Notion's code, not its message.\n- Not here: the hosted MCP's user identity, its search across connected\n apps, page duplication, team and skill search, attachment downloads, and\n page-to-skill conversion. Those need an OAuth connector.\n\n## Databases contain data sources\n\n- The id in a Notion database URL is a **database id**.\n `GET /v1/databases/{database_id}` through `notion_api_read` lists its\n `data_sources`, usually one. `integration_search` returns data sources\n directly and skips that step.\n- `integration_get_data_source_schema` and `integration_query_data_source`\n take the **data_source_id**, and so does `integration_create_page` when\n adding a row.\n\nSo the sequence for \"find rows in this database\" is the database read, then\n`integration_get_data_source_schema`, then `integration_query_data_source`.\n\n## Reading\n\n- `integration_get_page` returns properties. To read or summarize a whole\n body, prefer `notion_api_read` `GET /v1/pages/{page_id}/markdown`: one\n request returns every nested level as Markdown (headings, lists, to-dos,\n toggles, tables, code, links, mentions, equations). A huge page comes back\n with `truncated: true` and the unread blocks in `unknown_block_ids`; read\n each id as a `page_id` on the same path.\n- `integration_get_page_content` returns the body as flat blocks with ids,\n for quoting a block or appending after one. Nested blocks (toggles, list\n children, table rows) need `depth`, and each level multiplies requests. A\n deep read stops at an internal ceiling and reports `truncated: true` rather\n than spending the whole rate-limit budget.\n- Notion truncates `title`, `rich_text`, `relation`, and `people` at 25\n entries. `integration_get_page` reports each in `truncated_properties` as\n `{ name, id }`; read the complete value with `notion_api_read`\n `GET /v1/pages/{page_id}/properties/{property_id}` and follow its cursor.\n- Every named read projects Notion's payload to ids, plain text, and\n flattened property values. Pass `raw: true` for Notion's untouched response\n when a specific field is missing. It is much larger. Narrow\n `integration_query_data_source` and `integration_get_page` with `properties`\n when the goal is fewer fields. `integration_get_page_content` with\n `raw: true` returns one level and ignores `depth`.\n\n## Writing content\n\n`integration_append_blocks` adds children and nothing else. It cannot move,\nreorder, or replace an existing block, and a block appended through the API can\nnever be relocated by it afterwards. Get the position right the first time with\n`position` and `after_block_id`. Notion caps one call at 100 blocks. Text\nlonger than Notion's 2,000-character run limit is split into runs for you.\n\nFor formatted content, write Markdown to a page with `notion_api_write`\n`PATCH /v1/pages/{page_id}/markdown`. Append with\n`{\"type\":\"insert_content\",\"insert_content\":{\"content\":\"## Notes\\n- one\",\"position\":{\"type\":\"end\"}}}`\n(`start` prepends). Edit text in place with `update_content`, whose\n`content_updates` are `{ old_str, new_str }` pairs copied exactly from the\npage's Markdown, and `replace_content` swaps the whole page for `new_str`.\nNotion refuses an edit that would delete a child page or database with a\n`validation_error` naming them. Removing ordinary text needs no flag; to delete\nthose children, set `allow_deleting_content: true` inside the command body\n(`{\"type\":\"update_content\",\"update_content\":{\"content_updates\":[…],\"allow_deleting_content\":true}}`),\nand leave it out unless the task is to delete them. `integration_create_page`\ntakes the same Markdown for a new page's body.\n\n## Pagination\n\n- Named list tools take `page_size` (max 100) and return `has_more` with\n `next_cursor`; pass it back as `start_cursor`.\n- Generic results arrive as `{ status, data, page? }`. On a list,\n `page.next` is the cursor and `page.param` is `start_cursor`: send it in\n `query` for a GET, and in `body` when `page.in` is `body`, as on the POST\n queries. Pass `select` dot paths relative to `data`, such as `results.id`;\n `page` survives any `select`.\n- A view query is created with `notion_api_write`\n `POST /v1/views/{view_id}/queries`, a write because Notion stores it until\n `expires_at`. Its first page names the continuation in `page.path`: read\n `GET /v1/views/{view_id}/queries/{query_id}` with `start_cursor` in `query`.\n- Meeting-note queries have no cursor. `limit` caps at 50, and\n `page.hasMore` without `page.next` means more notes exist: narrow the\n filter (for example by `created_time`) rather than paging.\n- A data source query stops at 10,000 rows. `integration_query_data_source`\n then returns `incomplete: true`, and a generic query `page.hasMore` without\n `page.next`: narrow the filter rather than trusting the count.\n- Cursors are opaque: never parse or construct one. Follow pages inside\n `execute_code` and reduce there.\n\n## Failures worth reading carefully\n\n- **404** means \"no such object\" *or* \"not shared with this integration\", and\n Notion will not say which. Never conclude a page was deleted from it; check\n that the page is shared with the integration in Notion.\n- **403** is not an expired token. The integration is missing a capability\n (comment capabilities are off by default), the object was never shared, or\n the workspace is past its plan's block limit; Notion's message says which.\n Re-authorizing cannot fix it; an operator must change it in Notion.\n- **429** carries a retry window. Notion allows each integration 180 requests\n a minute (600 on Business and Enterprise) plus a budget shared by the whole\n workspace; Connecta admits 180 calls a minute, three at a time, per runtime.\n Wait out the window rather than retrying immediately. A 429 that says the\n integration's API access is blocked is not retryable.\n- **5xx on a write** may still have been saved. When Notion names the\n object it committed, the failure says so: read that object instead of\n repeating the write. Otherwise check the target before trying again.\n",
9
+ "shared": "\n## Notion's data model\n\n- A database is a container; its rows and schema live in a *data source*\n inside it. Queries, schema reads, and new rows take the data source, and\n Notion rejects a database id in its place.\n- Property names in filters, sorts, and writes must match the schema exactly,\n including case. Read the schema before composing one.\n- A `status` write must use an existing option name; inventing one fails.\n A new `select` or `multi_select` name does not fail: when the connection\n can write to the data source, Notion adds it to the schema as a new\n option. Use the options the schema lists unless a schema change is meant.\n- Writes **replace** a property. Sending one item to a `multi_select` or\n `relation` drops the others, so send the complete intended value.\n- `rollup`, `formula`, `unique_id`, and the created and edited fields are\n computed. They cannot be written.\n- A data source's title column is rarely called \"title\". Read its name from\n the schema before creating or renaming a row.\n- Notion has **no idempotency key**. A retried page create or comment makes a\n duplicate. Confirm with a search before repeating a write that may have\n partially succeeded.\n"
9
10
  }
10
11
  };
@@ -0,0 +1,31 @@
1
+ // Notion's reviewed value-safety table (#801), on the shared mechanism in
2
+ // `../_shared/rest/value-safety.ts`: every operation the detector flags in
3
+ // the pinned spec (`value-safety.candidates.json`) has one verdict here, and
4
+ // `value-safety.node.test.ts` runs the shared harness over it.
5
+ //
6
+ // Notion's API returns little that is a credential: an integration token
7
+ // never appears in a response. The OAuth endpoints mint, introspect, and
8
+ // revoke tokens with a public integration's client secret and are refused.
9
+ // A file upload's `upload_url` is Notion's own send endpoint and needs the
10
+ // integration token, so it is kept. Notion-hosted file URLs in pages and
11
+ // blocks are pre-signed: the shared URL rule withholds their signature,
12
+ // credential, and security-token query parameters on every response, so a
13
+ // returned URL names the file without granting access to it.
14
+ import { refuse, safe } from "../_shared/rest/value-safety.js";
15
+ const OAUTH = "Notion's OAuth token endpoints are not reachable through Connecta: they mint and revoke credentials with a public integration's client secret, which this connector never holds.";
16
+ const UPLOAD = "A file upload's upload_url is Notion's own send endpoint, which needs the integration token; it grants nothing alone.";
17
+ const AGENT = "Custom agents name their connected MCP server's host, never its URL or credentials.";
18
+ const OPERATIONS = {
19
+ "POST /v1/oauth/token": refuse(OAUTH),
20
+ "POST /v1/oauth/introspect": refuse(OAUTH),
21
+ "POST /v1/oauth/revoke": refuse(OAUTH),
22
+ "GET /v1/users/me": safe("Answers the integration's bot user; no token."),
23
+ "GET /v1/file_uploads": safe(UPLOAD, ["results.upload_url"]),
24
+ "POST /v1/file_uploads": safe(UPLOAD, ["upload_url"]),
25
+ "GET /v1/file_uploads/{file_upload_id}": safe(UPLOAD, ["upload_url"]),
26
+ "POST /v1/file_uploads/{file_upload_id}/complete": safe(UPLOAD, ["upload_url"]),
27
+ "POST /v1/file_uploads/{file_upload_id}/send": safe(UPLOAD, ["upload_url"]),
28
+ "POST /v1/agents/query": safe(AGENT, ["results.connections.account.server_host"]),
29
+ "GET /v1/agents/{agent_id}": safe(AGENT, ["connections.account.server_host"]),
30
+ };
31
+ export const NOTION_VALUE_SAFETY = { title: "Notion", operations: OPERATIONS };
@@ -4,6 +4,12 @@ export declare const PLANNING_CENTER_API_BASE_URL = "https://api.planningcentero
4
4
  /** The products behind `/<app>/v2`, in the order their guides list them. */
5
5
  export declare const PLANNING_CENTER_APPS: readonly ["people", "services", "groups", "check-ins", "calendar", "registrations", "giving", "publishing", "webhooks", "api"];
6
6
  export type PlanningCenterApp = (typeof PLANNING_CENTER_APPS)[number];
7
+ /**
8
+ * A product a deployment may remove from this connection. People is the one
9
+ * it may not: every product joins through its person ids, and the credential
10
+ * test reads `/people/v2/me`.
11
+ */
12
+ export type PlanningCenterExcludableApp = Exclude<PlanningCenterApp, "people">;
7
13
  /**
8
14
  * One reviewed `X-PCO-API-Version` per product, sent on every request.
9
15
  *
@@ -46,6 +52,27 @@ export interface PlanningCenterOptions {
46
52
  userAgent?: string;
47
53
  /** Default `per_page` for list tools. Defaults to 25; Planning Center's maximum is 100. */
48
54
  defaultPageSize?: number;
55
+ /**
56
+ * Products this connection never reaches, e.g. `["giving"]` for a
57
+ * deployment that keeps agents away from donor and financial data. Their
58
+ * named tools leave the catalog, the guide and description stop naming
59
+ * them, and the hatches refuse their paths before any request is sent.
60
+ * While any product is excluded, hatch paths must be literal, People lists
61
+ * are unreachable, and existing webhook subscriptions and their payloads
62
+ * are refused, because each could carry an excluded product's data.
63
+ * Defaults to none.
64
+ *
65
+ * Exclusion guarantees agents reach no excluded-product endpoint, no live
66
+ * cross-product query (People lists), and no webhook payload or
67
+ * subscription toggle. It does not hide records the church's automations
68
+ * or staff have copied into a reachable product: a Giving automation that
69
+ * adds first-time donors to a People workflow leaves readable workflow
70
+ * cards, as do notes, custom fields, and form answers. For a hard
71
+ * boundary, use a personal access token from a user with No Access in
72
+ * the excluded product; Planning Center sets permissions per product, per
73
+ * user, and a token carries its user's. Exclusion is defense in depth.
74
+ */
75
+ excludeApps?: readonly PlanningCenterExcludableApp[];
49
76
  /**
50
77
  * Replaces the default per-runtime budget (100 calls per 20 seconds, five at
51
78
  * a time). Split it when two connections share one token's user, because
@@ -237,7 +237,7 @@ function isApp(value) {
237
237
  * normalization the guarded transport applies — so `..` cannot carry a request
238
238
  * from `/people/v2` to `/oauth` or anywhere else outside a product.
239
239
  */
240
- function appForPath(path) {
240
+ function appForPath(path, apps) {
241
241
  let normalized;
242
242
  if (path.startsWith("/") && !path.startsWith("//")) {
243
243
  try {
@@ -249,10 +249,145 @@ function appForPath(path) {
249
249
  }
250
250
  const app = normalized === undefined ? undefined : APP_PATH.exec(normalized)?.[1];
251
251
  if (!app || !isApp(app)) {
252
- throw new ConnectorCallError("invalid_args", `A Planning Center path begins with /<app>/v2, where app is one of ${PLANNING_CENTER_APPS.join(", ")} — for example /people/v2/people. No host, query string, or traversal.`);
252
+ throw new ConnectorCallError("invalid_args", `A Planning Center path begins with /<app>/v2, where app is one of ${apps.join(", ")} — for example /people/v2/people. No host, query string, or traversal.`);
253
253
  }
254
254
  return app;
255
255
  }
256
+ /** Display names, in guide order. The guide never lists `api` as a product. */
257
+ const APP_TITLES = {
258
+ people: "People",
259
+ services: "Services",
260
+ groups: "Groups",
261
+ "check-ins": "Check-Ins",
262
+ calendar: "Calendar",
263
+ registrations: "Registrations",
264
+ giving: "Giving",
265
+ publishing: "Publishing",
266
+ webhooks: "Webhooks",
267
+ api: "API",
268
+ };
269
+ /**
270
+ * A path's segments, each percent-decoded once, or a refusal when the path is
271
+ * not literal. While a product is excluded no path may lean on normalization:
272
+ * a dot segment, an empty segment, a backslash, whitespace, or an encoded
273
+ * `/`, `\`, `?`, `#`, or `%` could each mean one thing here and another to a
274
+ * server that decodes or resolves it, so none is admitted at all rather than
275
+ * reasoned about.
276
+ */
277
+ function literalSegments(path) {
278
+ const ambiguous = () => new ConnectorCallError("invalid_args", "While this deployment excludes a Planning Center product, a path must be literal: no . or .. segments, empty segments, backslashes, whitespace, or percent-encoded /, \\, ?, #, or %.");
279
+ if (!path.startsWith("/"))
280
+ throw ambiguous();
281
+ const raw = path.slice(1).split("/");
282
+ return raw.map((segment, index) => {
283
+ if (segment === "" && index !== raw.length - 1)
284
+ throw ambiguous();
285
+ let decoded;
286
+ try {
287
+ decoded = decodeURIComponent(segment);
288
+ }
289
+ catch {
290
+ throw ambiguous();
291
+ }
292
+ const control = [...decoded].some((char) => char <= " " || char === "\x7f");
293
+ if (decoded === "." || decoded === ".." || control || /[/\\?#%]/.test(decoded))
294
+ throw ambiguous();
295
+ return decoded;
296
+ });
297
+ }
298
+ /**
299
+ * People resources a list is built from or answers with. A list's conditions
300
+ * can draw on any product — a Giving condition makes a donor list — and one
301
+ * list can be a condition of another, so while any product is excluded none
302
+ * of them is read, by path or by `include`.
303
+ */
304
+ const LIST_RESOURCES = new Set(["lists", "list_results", "rules", "conditions"]);
305
+ /** The People tools that read or refresh a list, removed with any exclusion. */
306
+ const LIST_TOOLS = new Set(["list_lists", "list_list_people", "run_list"]);
307
+ /** The webhook subscription collections, current and 2018-08-01. */
308
+ const SUBSCRIPTION_COLLECTIONS = new Set(["webhook_subscriptions", "subscriptions"]);
309
+ /**
310
+ * Refuse, before anything is sent, a request that would reach an excluded
311
+ * product. It reads literal segments, so it runs twice: on the path as given,
312
+ * and on the exact URL the transport is about to fetch (`admit`), which is the
313
+ * one that counts. Five doors lead to an excluded product:
314
+ *
315
+ * - its own paths, in any case or percent-encoding of a literal segment;
316
+ * - People lists, rules, conditions, and results, whose criteria can come from
317
+ * the excluded product, by path or by `include`;
318
+ * - a new webhook subscription to its events, which delivers its records to
319
+ * any URL — so a new subscription must name a reachable product's event;
320
+ * - an existing subscription, changed by id: a path names a subscription by
321
+ * id, not by product, so no subscription is enabled, disabled, deleted, or
322
+ * re-keyed while any product is excluded; and
323
+ * - webhook event and delivery payloads, which carry the record that fired
324
+ * them, refused for every subscription for the same reason.
325
+ *
326
+ * People documents no other `include` or attribute that reaches another
327
+ * product's records.
328
+ */
329
+ function refuseExcluded(segments, query, request, excluded) {
330
+ const disabled = (app, detail) => new ConnectorCallError("provider_permission_denied", `Planning Center ${APP_TITLES[app]} (/${app}/v2) is disabled in this deployment, so this connection ${detail}. Retrying will not help; only an operator can remove "${app}" from excludeApps.`, { retryable: false });
331
+ const because = (detail) => new ConnectorCallError("provider_permission_denied", `This deployment disables Planning Center ${series([...excluded].map((name) => APP_TITLES[name]))}, and ${detail} Retrying will not help.`, { retryable: false });
332
+ const lower = segments.map((segment) => segment.toLowerCase());
333
+ const app = lower[0] ?? "";
334
+ if (isApp(app) && excluded.has(app))
335
+ throw disabled(app, "does not reach its endpoints");
336
+ // Lists are refused and workflows are not. A list is a live query: reading
337
+ // it evaluates its conditions, Giving ones included, at read time, so it
338
+ // is the excluded product's API by another path. A workflow card is a
339
+ // stored People record. A Giving automation can create one, but so can
340
+ // staff, and once created it is People data, like a note or custom field
341
+ // value typed from a donor's details. Exclusion guards products' APIs,
342
+ // not provenance; refusing workflows would cost every exclusion a core
343
+ // People feature and argue equally for notes, fields, forms, and groups.
344
+ // The hard boundary is the token user's own per-product permissions.
345
+ const lists = "a People list's conditions can draw on a disabled product, so this connection reads no list, list result, rule, or condition.";
346
+ if (app === "people" && lower.some((segment) => LIST_RESOURCES.has(segment)))
347
+ throw because(lists);
348
+ for (const [key, value] of query) {
349
+ if (/include/i.test(key) &&
350
+ value
351
+ .toLowerCase()
352
+ .split(/[\s,.]+/)
353
+ .some((part) => LIST_RESOURCES.has(part))) {
354
+ throw because(lists);
355
+ }
356
+ }
357
+ if (app !== "webhooks")
358
+ return;
359
+ if (lower.some((segment) => /^(?:events|deliveries)\b/.test(segment))) {
360
+ throw because("a webhook event or delivery payload may carry any product's records, so this connection does not read them. Subscriptions themselves stay readable.");
361
+ }
362
+ if (request.method === "GET" || request.method === "HEAD")
363
+ return;
364
+ const resource = lower.slice(2).filter((segment) => segment !== "");
365
+ if (request.method !== "POST" || resource.length !== 1 || !SUBSCRIPTION_COLLECTIONS.has(resource[0])) {
366
+ throw because("a webhook subscription's id does not name its product, so this connection does not enable, disable, delete, or re-key an existing subscription.");
367
+ }
368
+ const data = asRecord(request.body)["data"];
369
+ const name = Array.isArray(data) ? undefined : asRecord(asRecord(data)["attributes"])["name"];
370
+ const event = typeof name === "string" ? /^([a-z-]+)\.v2\.events\./.exec(name.trim().toLowerCase())?.[1] : undefined;
371
+ if (event === undefined || !isApp(event)) {
372
+ throw because("a new webhook subscription must name one product's event, such as people.v2.events.person.created, in data.attributes.name.");
373
+ }
374
+ if (excluded.has(event))
375
+ throw disabled(event, "cannot subscribe to its webhook events");
376
+ }
377
+ /**
378
+ * The exclusion, enforced on the exact URL about to be fetched: after the
379
+ * transport has resolved and encoded it, so what is checked is what Planning
380
+ * Center routes. The origin and base path are asserted again here, not taken
381
+ * on trust from the confinement before it.
382
+ */
383
+ function admit(baseUrl, url, request, excluded) {
384
+ const base = new URL(baseUrl);
385
+ const basePath = base.pathname.replace(/\/+$/, "");
386
+ if (url.origin !== base.origin || !url.pathname.startsWith(`${basePath}/`) || url.hash) {
387
+ throw new ConnectorCallError("invalid_args", "A Planning Center request left the Planning Center API origin.");
388
+ }
389
+ refuseExcluded(literalSegments(url.pathname.slice(basePath.length)), url.searchParams, request, excluded);
390
+ }
256
391
  function errorDetail(payload, status) {
257
392
  const parts = [];
258
393
  for (const entry of asArray(asRecord(payload)["errors"])) {
@@ -309,9 +444,12 @@ function planningCenterFailure(status, headers, payload) {
309
444
  retryable: false,
310
445
  });
311
446
  }
312
- function planningCenterCall(send, versions) {
447
+ function planningCenterCall(send, versions, excluded) {
448
+ const apps = PLANNING_CENTER_APPS.filter((app) => !excluded.has(app));
313
449
  return async (request, ctx) => {
314
- const app = appForPath(request.path);
450
+ if (excluded.size > 0)
451
+ refuseExcluded(literalSegments(request.path), [], request, excluded);
452
+ const app = appForPath(request.path, apps);
315
453
  return await send(compact({
316
454
  method: request.method,
317
455
  path: request.path,
@@ -787,11 +925,11 @@ function personAttributes(args) {
787
925
  };
788
926
  }
789
927
  const WORKFLOW_CARD_ACTIONS = ["promote", "go_back", "skip_step", "snooze", "unsnooze", "remove", "restore"];
790
- function tools(call, defaultPageSize) {
928
+ function tools(call, defaultPageSize, excluded) {
791
929
  const perPage = (args) => args["perPage"] ?? defaultPageSize;
792
930
  const list = (spec) => listTool(call, defaultPageSize, spec);
793
- return [
794
- // --- Hatches ------------------------------------------------------------
931
+ // --- Hatches --------------------------------------------------------------
932
+ const hatches = [
795
933
  {
796
934
  name: "pco_api_get",
797
935
  description: "Call any Planning Center GET endpoint in any product and return its untouched JSON:API body. Prefer a named read; this one does not flatten or resolve includes.",
@@ -813,7 +951,7 @@ function tools(call, defaultPageSize) {
813
951
  },
814
952
  {
815
953
  name: "pco_api_mutate",
816
- description: "Call any Planning Center POST, PATCH, or DELETE endpoint with a JSON:API body. The approval-gated hatch for writes, actions, and money movement no named tool covers.",
954
+ description: `Call any Planning Center POST, PATCH, or DELETE endpoint with a JSON:API body. The approval-gated hatch for ${excluded.has("giving") ? "writes and actions" : "writes, actions, and money movement"} no named tool covers.`,
817
955
  annotations: DESTRUCTIVE,
818
956
  inputSchema: input({
819
957
  method: {
@@ -844,7 +982,9 @@ function tools(call, defaultPageSize) {
844
982
  }), ctx)) ?? null,
845
983
  }),
846
984
  },
847
- // --- People -------------------------------------------------------------
985
+ ];
986
+ // --- People ---------------------------------------------------------------
987
+ const people = [
848
988
  {
849
989
  name: "get_me",
850
990
  description: "Get the Planning Center person this token acts as, with the organization's name and time zone. Supplies personId for my-schedule and assignee questions.",
@@ -1292,7 +1432,9 @@ function tools(call, defaultPageSize) {
1292
1432
  };
1293
1433
  },
1294
1434
  }),
1295
- // --- Services -----------------------------------------------------------
1435
+ ];
1436
+ // --- Services -------------------------------------------------------------
1437
+ const services = [
1296
1438
  list({
1297
1439
  name: "list_service_types",
1298
1440
  description: "List Services service types — the recurring services such as Sunday Morning. Supplies serviceTypeId for plans and teams.",
@@ -1596,7 +1738,9 @@ function tools(call, defaultPageSize) {
1596
1738
  keys: relatedMany(row, "keys", index).map((key) => pick(key, ["name", "starting_key", "ending_key"])),
1597
1739
  }),
1598
1740
  }),
1599
- // --- Groups -------------------------------------------------------------
1741
+ ];
1742
+ // --- Groups ---------------------------------------------------------------
1743
+ const groups = [
1600
1744
  list({
1601
1745
  name: "list_groups",
1602
1746
  description: "List Groups groups with type, schedule, and member count. Archived groups are excluded unless asked for; the API cannot create a group.",
@@ -1712,7 +1856,9 @@ function tools(call, defaultPageSize) {
1712
1856
  group_id: relId(row, "group") ?? null,
1713
1857
  }),
1714
1858
  }),
1715
- // --- Check-Ins (read-only product) ---------------------------------------
1859
+ ];
1860
+ // --- Check-Ins (read-only product) -----------------------------------------
1861
+ const checkIns = [
1716
1862
  list({
1717
1863
  name: "list_check_in_events",
1718
1864
  description: "List Check-Ins events, the recurring things people check in to. Supplies eventId for event periods and check-ins.",
@@ -1806,7 +1952,9 @@ function tools(call, defaultPageSize) {
1806
1952
  event_period_id: relId(row, "event_period") ?? null,
1807
1953
  }),
1808
1954
  }),
1809
- // --- Calendar (events read-only) ----------------------------------------
1955
+ ];
1956
+ // --- Calendar (events read-only) ------------------------------------------
1957
+ const calendar = [
1810
1958
  list({
1811
1959
  name: "list_calendar_event_instances",
1812
1960
  description: "List what is on the Calendar in a date range: each occurrence with times and location, recurrence expanded. Events cannot be written through the API.",
@@ -1848,7 +1996,9 @@ function tools(call, defaultPageSize) {
1848
1996
  event_id: relId(row, "event") ?? null,
1849
1997
  }),
1850
1998
  }),
1851
- // --- Registrations (read-only product) ----------------------------------
1999
+ ];
2000
+ // --- Registrations (read-only product) ------------------------------------
2001
+ const registrations = [
1852
2002
  list({
1853
2003
  name: "list_signups",
1854
2004
  description: "List Registrations signups (registration events) with open, close, and capacity state. Read-only: the API cannot register anyone.",
@@ -1917,7 +2067,9 @@ function tools(call, defaultPageSize) {
1917
2067
  person_id: relId(row, "person") ?? null,
1918
2068
  }),
1919
2069
  }),
1920
- // --- Giving (reads; money moves only through pco_api_mutate) -------------
2070
+ ];
2071
+ // --- Giving (reads; money moves only through pco_api_mutate) ---------------
2072
+ const giving = [
1921
2073
  list({
1922
2074
  name: "list_donations",
1923
2075
  description: "List Giving donations with their fund designations, newest first, by date, fund, or donor. Amounts are integer cents. Read-only.",
@@ -2075,7 +2227,9 @@ function tools(call, defaultPageSize) {
2075
2227
  designations: designations(row, index),
2076
2228
  }),
2077
2229
  }),
2078
- // --- Webhooks -----------------------------------------------------------
2230
+ ];
2231
+ // --- Webhooks -------------------------------------------------------------
2232
+ const webhooks = [
2079
2233
  {
2080
2234
  name: "list_webhook_subscriptions",
2081
2235
  description: "List the organization's webhook subscriptions: event name, URL, and active state. Never returns the signing secret; that needs pco_api_get.",
@@ -2104,18 +2258,117 @@ function tools(call, defaultPageSize) {
2104
2258
  },
2105
2259
  },
2106
2260
  ];
2261
+ return {
2262
+ hatches,
2263
+ apps: {
2264
+ people,
2265
+ services,
2266
+ groups,
2267
+ "check-ins": checkIns,
2268
+ calendar,
2269
+ registrations,
2270
+ giving,
2271
+ publishing: [],
2272
+ webhooks,
2273
+ api: [],
2274
+ },
2275
+ };
2107
2276
  }
2108
2277
  // ---------------------------------------------------------------------------
2109
2278
  // Guide and construction
2110
2279
  // ---------------------------------------------------------------------------
2111
- function usageGuide(purpose, instructions) {
2280
+ /** `a, b, and c`, the way the guide and description list products. */
2281
+ function series(items) {
2282
+ return items.length < 3 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
2283
+ }
2284
+ /** The products the description names: those with named tools for staff work. */
2285
+ const DESCRIBED_APPS = [
2286
+ "people",
2287
+ "services",
2288
+ "groups",
2289
+ "check-ins",
2290
+ "calendar",
2291
+ "registrations",
2292
+ "giving",
2293
+ ];
2294
+ /** What a person id joins in each other product, for the guide's identity rule. */
2295
+ const PERSON_JOINS = [
2296
+ ["services", "Services schedules"],
2297
+ ["groups", "Groups memberships"],
2298
+ ["giving", "Giving donations"],
2299
+ ["check-ins", "Check-Ins"],
2300
+ ];
2301
+ /**
2302
+ * The maintained guide for the products this connection reaches. Every
2303
+ * product-specific line is its own fragment, so an excluded product is
2304
+ * neither described, pinned, nor named by a tool the guide points to, and an
2305
+ * exclusion adds the line stating what the hatches refuse. With nothing
2306
+ * excluded, the text is the whole guide as before; the `published` fragment
2307
+ * is that same text without a purpose.
2308
+ */
2309
+ function usageGuide(purpose, instructions, excluded) {
2310
+ const fragments = skill.fragments;
2311
+ const has = (app) => !excluded.has(app);
2312
+ const pair = (a, b, both, onlyA, onlyB) => has(a) && has(b) ? both : has(a) ? onlyA : has(b) ? onlyB : "";
2313
+ const apps = PLANNING_CENTER_APPS.filter(has);
2314
+ const joins = PERSON_JOINS.filter(([app]) => has(app)).map(([, label]) => label);
2112
2315
  const organizationInstructions = instructions?.trim();
2113
- return `${skill.fragments.guide_0}${purpose}${skill.fragments.guide_1}${PLANNING_CENTER_APPS.map((app) => `${app} ${PLANNING_CENTER_API_VERSIONS[app]}`).join(", ")}${skill.fragments.guide_2}${organizationInstructions ? `\n## ${skill.instructionsHeading}\n\n${organizationInstructions}\n` : ""}`;
2316
+ return [
2317
+ fragments.heading,
2318
+ apps
2319
+ .filter((app) => app !== "api")
2320
+ .map((app) => APP_TITLES[app])
2321
+ .join(", "),
2322
+ fragments.purpose,
2323
+ purpose,
2324
+ fragments.identity,
2325
+ joins.length > 0 ? series(joins) : "every People record",
2326
+ fragments.conventions,
2327
+ has("services") ? fragments.plans : "",
2328
+ fragments.writes,
2329
+ excluded.size === 0 ? fragments.people : fragments.people_without_lists,
2330
+ has("services") ? fragments.services : "",
2331
+ has("groups") ? fragments.groups : "",
2332
+ pair("check-ins", "registrations", fragments.check_ins_registrations, fragments.check_ins, fragments.registrations),
2333
+ has("calendar") ? fragments.calendar : "",
2334
+ has("giving") ? fragments.giving : "",
2335
+ pair("publishing", "api", fragments.publishing_api, fragments.publishing, fragments.api),
2336
+ fragments.hatches,
2337
+ apps.map((app) => `${app} ${PLANNING_CENTER_API_VERSIONS[app]}`).join(", "),
2338
+ fragments.pins,
2339
+ excluded.size === 0 ? "" : fragments.disabled,
2340
+ fragments.limits,
2341
+ organizationInstructions ? `\n## ${skill.instructionsHeading}\n\n${organizationInstructions}\n` : "",
2342
+ ].join("");
2343
+ }
2344
+ /**
2345
+ * `excludeApps` as a set, refusing at construction what cannot be meant
2346
+ * (INV-11): a product Planning Center does not have, a misspelling, or People.
2347
+ */
2348
+ function excludedApps(value) {
2349
+ const excluded = new Set();
2350
+ if (value === undefined)
2351
+ return excluded;
2352
+ const excludable = PLANNING_CENTER_APPS.filter((app) => app !== "people").join(", ");
2353
+ if (!Array.isArray(value)) {
2354
+ throw new Error(`planningCenter() excludeApps must be an array of app names from: ${excludable}.`);
2355
+ }
2356
+ for (const entry of value) {
2357
+ if (entry === "people") {
2358
+ throw new Error('planningCenter() excludeApps cannot exclude "people": every product joins through People person ids, and the credential test reads /people/v2/me.');
2359
+ }
2360
+ if (typeof entry !== "string" || !isApp(entry)) {
2361
+ const lower = typeof entry === "string" ? entry.trim().toLowerCase() : "";
2362
+ throw new Error(`planningCenter() excludeApps has unknown app ${JSON.stringify(entry)}; expected any of ${excludable}.${isApp(lower) && lower !== "people" ? ` App names are lowercase path segments: "${lower}".` : ""}`);
2363
+ }
2364
+ excluded.add(entry);
2365
+ }
2366
+ return excluded;
2114
2367
  }
2115
2368
  /** The closed options planningCenter() accepts; see `assertKnownOptions`. */
2116
2369
  const PLANNING_CENTER_OPTIONS = optionsOf()({
2117
2370
  ...PROVIDER_COMMON,
2118
- ...keys("userAgent", "defaultPageSize", "baseUrl"),
2371
+ ...keys("userAgent", "defaultPageSize", "excludeApps", "baseUrl"),
2119
2372
  });
2120
2373
  /** A maintained Planning Center Online connection over the REST API. */
2121
2374
  export const planningCenter = asProviderFactory({
@@ -2145,19 +2398,25 @@ function planningCenterConnector(id, options) {
2145
2398
  if (!userAgent || /[\r\n]/.test(userAgent)) {
2146
2399
  throw new Error("planningCenter() userAgent must be a non-empty single line.");
2147
2400
  }
2401
+ const excluded = excludedApps(options.excludeApps);
2148
2402
  const auth = PERSONAL_ACCESS_TOKEN;
2403
+ const baseUrl = options.baseUrl?.trim() || PLANNING_CENTER_API_BASE_URL;
2149
2404
  const send = guardedFetch({
2150
2405
  provider: "Planning Center",
2151
- baseUrl: options.baseUrl?.trim() || PLANNING_CENTER_API_BASE_URL,
2406
+ baseUrl,
2152
2407
  headers: { Accept: "application/json", "User-Agent": userAgent },
2153
2408
  maxResponseBytes: PLANNING_CENTER_MAX_RESPONSE_BYTES,
2154
2409
  authenticate: (ctx) => auth.headers(ctx),
2410
+ ...(excluded.size > 0
2411
+ ? { admit: (url, request) => admit(baseUrl, url, request, excluded) }
2412
+ : {}),
2155
2413
  });
2156
- const call = planningCenterCall(send, PLANNING_CENTER_API_VERSIONS);
2414
+ const call = planningCenterCall(send, PLANNING_CENTER_API_VERSIONS, excluded);
2415
+ const surface = tools(call, defaultPageSize, excluded);
2157
2416
  return api(id, {
2158
2417
  ...(options.authScope ? { authScope: options.authScope } : {}),
2159
2418
  title: options.title ?? "Planning Center",
2160
- description: `Planning Center church management — People, Services, Groups, Check-Ins, Calendar, Registrations, and Giving: ${purpose}`,
2419
+ description: `Planning Center church management — ${series(DESCRIBED_APPS.filter((app) => !excluded.has(app)).map((app) => APP_TITLES[app]))}: ${purpose}`,
2161
2420
  credential: auth.credential,
2162
2421
  async testCredentials(values, ctx) {
2163
2422
  try {
@@ -2186,7 +2445,7 @@ function planningCenterConnector(id, options) {
2186
2445
  },
2187
2446
  callAdmission: options.callAdmission ?? PLANNING_CENTER_ADMISSION,
2188
2447
  usageGuide: {
2189
- content: usageGuide(purpose, options.instructions),
2448
+ content: usageGuide(purpose, options.instructions, excluded),
2190
2449
  summary: "Person ids join every product, offset paging, per-product write limits, JSON:API hatches, pinned versions.",
2191
2450
  // Deliberately not `required`. Every named tool's schema is complete
2192
2451
  // enough to call it on its own; the guide carries the cross-product
@@ -2194,6 +2453,9 @@ function planningCenterConnector(id, options) {
2194
2453
  // it leaves the named surface or hits one of them.
2195
2454
  },
2196
2455
  ...(options.maxResultBytes !== undefined ? { maxResultBytes: options.maxResultBytes } : {}),
2197
- tools: tools(call, defaultPageSize),
2456
+ tools: [
2457
+ ...surface.hatches,
2458
+ ...PLANNING_CENTER_APPS.flatMap((app) => (excluded.has(app) ? [] : surface.apps[app])).filter((tool) => excluded.size === 0 || !LIST_TOOLS.has(tool.name)),
2459
+ ],
2198
2460
  });
2199
2461
  }