Skip to content

feat: Notes read tools + Twenty UI record URLs - #39

Open
maksimfedin wants to merge 15 commits into
jezweb:mainfrom
Cypher-Capital:fix-twenty-schema-drift
Open

feat: Notes read tools + Twenty UI record URLs#39
maksimfedin wants to merge 15 commits into
jezweb:mainfrom
Cypher-Capital:fix-twenty-schema-drift

Conversation

@maksimfedin

Copy link
Copy Markdown

Summary

Two feature commits on this branch:

  • cc86685 — Notes read tools. Adds search_notes, get_note, and list_notes to the MCP. Notes were previously write-only (only create_note existed), so nothing downstream could retrieve them. New tools mirror the existing contact/task read patterns and URL-enrich results via getRecordUrl('note', id).
  • 7044bf3 — Twenty UI record URLs. Adds a url field to every tool response and exposes a get_record_url tool so the client can deep-link into the Twenty UI.

Notes-feature details

  • searchNotes filters on title with ilike. Body-text filtering (bodyV2.markdown) is not yet wired because NoteFilterInput support for it is unverified against the live schema — easy follow-up once confirmed.
  • All three read methods select bodyV2 { markdown } and flatten to body for response consistency with createNote.
  • smithery.yaml: "Note Management" category updated; tool count 29 → 32.
  • Out of scope (deferred): update_note, delete_note, and noteTargets linking to companies/people/opportunities.

Test plan

  • npm run typecheck passes
  • Live smoke against dev Twenty instance:
    • create_note → capture id
    • get_note { id } returns full object with url, body, createdAt, updatedAt
    • search_notes { query: "<substring of title>" } surfaces the created note
    • list_notes { limit: 5 } returns newest-first, includes the created note
  • Verify NoteFilterInput.bodyV2.markdown.ilike in the Twenty GraphQL schema and widen search_notes to search body too (follow-up)
  • Regression: create_note still succeeds end-to-end

🤖 Generated with Claude Code

maksimfedin and others added 15 commits April 20, 2026 12:27
Enables claude.ai Custom Connectors to reach the server over public
HTTPS with Clerk OAuth (REQUIRE_AUTH=true) and a shared workspace-level
TWENTY_API_KEY. Secrets are marked sync:false so they stay out of git.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add Render Blueprint for public HTTPS deploy
The catch-all OPTIONS handler only advertised GET+OPTIONS and
Authorization/Content-Type headers. Browsers doing a preflight for
POST /mcp or for claude.ai's MCP-specific headers (Mcp-Session-Id,
MCP-Protocol-Version, Last-Event-ID) got blocked, producing a
"Couldn't reach the MCP server" error in claude.ai.

Advertise POST/DELETE, the MCP headers, and expose Mcp-Session-Id
so the client can read the session token off the response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix CORS preflight so claude.ai can connect
…in 401

claude.ai follows the Protected Resource Metadata spec (RFC 9728): it
fetches /.well-known/oauth-protected-resource/<resource-path>, where
<resource-path> is the resource it's trying to reach (/mcp). The server
only served the bare path, so claude.ai hit a 404 and gave up with
"Couldn't reach the MCP server".

Also missing: the WWW-Authenticate response on 401 did not point at the
metadata URL, so clients that fall back to reading that header couldn't
discover the auth server either.

- Route /.well-known/oauth-protected-resource/mcp to the same handler
  as the bare path
- Return resource = <serverUrl>/mcp in the metadata body (matches the
  resource the client is asking about)
- Add resource_metadata="..." to the WWW-Authenticate response on 401
- While fixing CORS on the 401 response, also allow MCP's session /
  protocol headers and expose WWW-Authenticate so browsers can read it

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…data

Fix protected-resource metadata path + WWW-Authenticate pointer
…tadata

Clerk rejects DCR registration when a client requests scopes outside
its allowlist (email, profile, public_metadata, private_metadata,
openid, offline_access). Our server advertised twenty:read and
twenty:write, so claude.ai's MCP connector included them in its dynamic
client registration request; Clerk rejected with invalid_client_metadata
and claude.ai aborted the flow at step=start_error before the user ever
saw the sign-in page.

Drop the custom scopes. Identity only — the Twenty API key is a
server-side global, so there is no per-scope authorization to enforce
at the OAuth layer anyway. Add offline_access to the authorization
server metadata so clients can request refresh tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop Twenty custom scopes — Clerk rejects them during DCR
…n JWTs

The Clerk token validator only understood Clerk session JWTs (three-part
JWT with an `sid` claim pointing at an active session). claude.ai's MCP
connector obtains an OAuth 2.0 access token via the authorization_code
grant — these have no `sid`, so validation returned "No session ID in
token" and every authenticated request got 401. Post-sign-in, claude.ai
reported "Your account was authorized but the integration rejected the
credentials" and reverted the connection.

Add a fallback path: when the token is not a parseable session JWT,
call Clerk's /oauth/userinfo endpoint with it as a Bearer header. If
Clerk accepts the token, take user_id/sub from the response.

Session JWTs still take the fast path (no extra HTTP call), so existing
clients are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Validate OAuth access tokens via Clerk userinfo
The /mcp handler was creating a fresh McpServer + StreamableHTTPServerTransport
on every request — each with a brand-new randomUUID session ID. MCP's
Streamable HTTP protocol requires sticky sessions: initialize returns a
session ID, and the client sends it back as the Mcp-Session-Id header on
every follow-up call (tools/list, tool invocations). Without a session
map, the follow-up requests hit a fresh transport whose session ID
doesn't match the header, so tools/list returned nothing and claude.ai
reported "This connector has no tools available."

Add the canonical session-keyed transports map and route requests:

  - Known session ID -> reuse its transport
  - No session ID + initialize request -> build server, register tools,
    create a new transport; register it in the map when the SDK fires
    onsessioninitialized, delete on close
  - Otherwise (stale session, or non-init without a session) -> 400

Dropped the experimental.authentication capability payload — OAuth
discovery already happens via /.well-known, and the capability shape
was never part of the MCP spec; keeping it just noises up the init
response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Persist MCP sessions across requests (tools/list was returning empty)
…schema

Three write/read paths were broken by schema drift in Twenty's GraphQL API:

1. searchOpportunities passed `$skip: Int` but Twenty's `opportunities`
   query only accepts `first / last / offset / before / after`. Every
   call returned "Argument not allowed: skip".

2. createTask sent a `body: String` field that no longer exists on
   TaskCreateInput — Twenty moved plain-text task bodies to
   `bodyV2: RichTextCreateInput` (a rich-text block with `markdown` and
   `blocknote` string fields). Creating a task with a body returned
   "Object task doesn't have any 'body' field".

3. createNote had the same drift on NoteCreateInput. The tool schema
   advertises `body` as required, so every create_note call failed.

Keep the MCP tool schema's simple `body: string` input (it's the
friendliest contract for callers) and translate it to
`bodyV2: { markdown: body }` at the GraphQL boundary. Update the
mutation response selections to read `bodyV2.markdown` and repackage
it back into `body` in the returned object, so upstream code that
reads `.body` keeps working.

searchOpportunities: s/skip/offset/ — that's the canonical Twenty name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every record-returning tool now emits the Twenty UI URL alongside the ID
so the model can hand users a link they can open in Twenty — including
for operations the MCP does not expose (delete, bulk edits, workflow
changes). New TWENTY_WEB_URL env var (optional; derived from
TWENTY_BASE_URL by swapping the api. host prefix for app.) configures
the UI host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Notes were write-only in the MCP — create_note existed but there was
no way to search, fetch, or list them. This adds three read tools that
mirror the existing contact/task patterns and URL-enrich responses via
getRecordUrl('note', id).

- searchNotes filters on title (ilike); bodyV2.markdown filtering not
  yet wired since NoteFilterInput support for it is unverified.
- getNote fetches a single note by id.
- listNotes returns the most recent notes, newest first.
- All three select bodyV2 { markdown } and flatten it back to body for
  response consistency with createNote.
- smithery.yaml: Note Management category updated, tool count 29 → 32.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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