feat: Notes read tools + Twenty UI record URLs - #39
Open
maksimfedin wants to merge 15 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two feature commits on this branch:
cc86685— Notes read tools. Addssearch_notes,get_note, andlist_notesto the MCP. Notes were previously write-only (onlycreate_noteexisted), so nothing downstream could retrieve them. New tools mirror the existing contact/task read patterns and URL-enrich results viagetRecordUrl('note', id).7044bf3— Twenty UI record URLs. Adds aurlfield to every tool response and exposes aget_record_urltool so the client can deep-link into the Twenty UI.Notes-feature details
searchNotesfilters ontitlewithilike. Body-text filtering (bodyV2.markdown) is not yet wired becauseNoteFilterInputsupport for it is unverified against the live schema — easy follow-up once confirmed.bodyV2 { markdown }and flatten tobodyfor response consistency withcreateNote.smithery.yaml: "Note Management" category updated; tool count 29 → 32.update_note,delete_note, andnoteTargetslinking to companies/people/opportunities.Test plan
npm run typecheckpassescreate_note→ captureidget_note { id }returns full object withurl,body,createdAt,updatedAtsearch_notes { query: "<substring of title>" }surfaces the created notelist_notes { limit: 5 }returns newest-first, includes the created noteNoteFilterInput.bodyV2.markdown.ilikein the Twenty GraphQL schema and widensearch_notesto search body too (follow-up)create_notestill succeeds end-to-end🤖 Generated with Claude Code