-
Notifications
You must be signed in to change notification settings - Fork 1
Update Notion example and streamed response ordering #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
shreyash-chonkie
wants to merge
2
commits into
main
Choose a base branch
from
codex/update-notion-alpha15
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,8 +8,9 @@ server and adds native React widgets for the tool results. | |
| - 1:1 Sidecar tool folders for the public Notion MCP tool names. | ||
| - An explicit `authorize` tool that returns the user's Notion OAuth link before | ||
| they run other Notion tools. | ||
| - WorkOS AuthKit as the MCP authorization server. | ||
| - WorkOS Vault storage for each user's upstream Notion MCP token. | ||
| - WorkOS-managed API keys as the MCP bearer credential. | ||
| - WorkOS Vault storage for the upstream Notion MCP token, keyed by the API | ||
| key owner. | ||
| - Streamable HTTP calls to `https://mcp.notion.com/mcp` with the official MCP | ||
| TypeScript SDK. | ||
| - Native widgets for search, fetch, read/query, metadata, and write results. | ||
|
|
@@ -73,39 +74,40 @@ This example is intentionally not a root npm workspace, so Vercel installs | |
|
|
||
| ## Auth | ||
|
|
||
| Hosted Notion MCP requires a Notion-audience OAuth token. This example keeps | ||
| Sidecar spec-compliant by making this MCP server the resource server and using | ||
| WorkOS AuthKit as its authorization server. It stores the separate upstream | ||
| Notion MCP token in WorkOS Vault, keyed by the authenticated WorkOS user id. | ||
| Hosted Notion MCP requires a Notion-audience OAuth token. This example uses a | ||
| WorkOS-managed API key as the MCP bearer credential. The server validates the | ||
| incoming bearer token with WorkOS API Keys and stores the separate upstream | ||
| Notion MCP token in WorkOS Vault under the validated key owner. | ||
|
|
||
| This keeps the Sidecar dev harness simple: paste the WorkOS organization API key | ||
| into the bearer-token field and the harness sends it as the standard MCP | ||
| `Authorization: Bearer ...` header. | ||
|
|
||
| Configure: | ||
|
|
||
| ```sh | ||
| WORKOS_CLIENT_ID=client_... | ||
| WORKOS_API_KEY_NOTION=sk_... | ||
| WORKOS_AUTHKIT_DOMAIN=your-subdomain.authkit.app | ||
| SIDECAR_MCP_URL=https://sidecar-notion.vercel.app/mcp | ||
| SIDECAR_PUBLIC_URL=https://sidecar-notion.vercel.app | ||
| ``` | ||
|
|
||
| WorkOS MCP auth requires Connect configuration in the WorkOS dashboard: | ||
|
|
||
| 1. Enable Client ID Metadata Document (CIMD). Dynamic Client Registration | ||
| (DCR) is optional for clients that have not adopted CIMD yet. | ||
| 2. Add `https://sidecar-notion.vercel.app/mcp` as a Resource Indicator. | ||
| 3. Use the AuthKit domain shown in WorkOS, for example | ||
| `your-subdomain.authkit.app`, as `WORKOS_AUTHKIT_DOMAIN`. | ||
|
|
||
| `auth.ts` verifies AuthKit access tokens against the AuthKit issuer and JWKS | ||
| endpoint. Tool execution reads the user's Notion MCP token from WorkOS Vault | ||
| with an object name derived from the WorkOS user id and a Vault key context | ||
| containing `user_id` and `data_type=notion_mcp_token`. | ||
|
|
||
| When a user has no stored Notion token, ask the model to run `authorize` first. | ||
| That tool returns a Notion OAuth link for the current authenticated WorkOS user. | ||
| If the user calls another Notion tool before linking, the tool result also | ||
| includes the same authorization link. The flow uses Notion's MCP OAuth | ||
| discovery, dynamic client registration, PKCE, and the local callback route at | ||
| `/notion/oauth/callback`. The callback stores the Notion access token and | ||
| refresh token in WorkOS Vault. Access tokens are refreshed from Vault before | ||
| upstream tool calls when they are close to expiry. | ||
| Create an organization-owned or user-owned API key in WorkOS AuthKit, then | ||
| either paste it into the local harness bearer-token dialog or set it as | ||
| `MCP_BEARER` before starting dev. User-owned keys still require an organization | ||
| membership; WorkOS uses that membership to decide which organization the key can | ||
| access. | ||
|
|
||
| `auth.ts` validates `Authorization: Bearer <WorkOS API key>` with WorkOS. Tool | ||
| execution reads the Notion MCP token from WorkOS Vault with an object name | ||
| derived from the validated key owner and a Vault key context containing | ||
| `user_id`, `organization_id` when present, and `data_type=notion_mcp_token`. | ||
|
|
||
| When Vault has no stored Notion token for the configured owner id, ask the model | ||
| to run `authorize` first. That tool returns a Notion OAuth link. If the user | ||
| calls another Notion tool before linking, the tool result also includes the same | ||
| authorization link. The flow uses Notion's MCP OAuth discovery, dynamic client | ||
| registration, PKCE, and the local callback route at `/notion/oauth/callback`. | ||
| The callback stores the Notion access token and refresh token in WorkOS Vault. | ||
|
Comment on lines
+105
to
+111
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. dwerwer |
||
| Access tokens are refreshed from Vault before upstream tool calls when they are | ||
| close to expiry. | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,142 +1,235 @@ | ||
| /** | ||
| * WorkOS AuthKit authentication for the hosted Notion MCP example. | ||
| * WorkOS API-key authentication for the private Notion MCP example. | ||
| * | ||
| * Sidecar is the MCP resource server, AuthKit is the OAuth authorization | ||
| * server, and WorkOS Vault stores the per-user upstream Notion MCP token. | ||
| * The Sidecar dev harness and MCP clients send a standard bearer token. This | ||
| * file validates that bearer value with WorkOS API Keys, then uses the API key | ||
| * owner as the stable Vault owner for the upstream Notion token. | ||
| */ | ||
| import "dotenv/config"; | ||
| import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; | ||
| import { WorkOS } from "@workos-inc/node"; | ||
| import { auth, type AuthSession } from "sidecar-ai"; | ||
|
|
||
| const DEFAULT_AUTHKIT_ISSUER = "https://signin.workos.com"; | ||
|
|
||
| /** Verified AuthKit claims accepted by this example. */ | ||
| export type WorkOSMcpClaims = JWTPayload & { | ||
| scope?: string; | ||
| scp?: string[]; | ||
| client_id?: string; | ||
| org_id?: string; | ||
| organization_id?: string; | ||
| /** Claims attached to validated WorkOS API-key sessions in this example. */ | ||
| export type NotionApiKeyClaims = { | ||
| sub: string; | ||
| scope: string; | ||
| kind: "workos-api-key"; | ||
| apiKeyId: string; | ||
| ownerType: "organization" | "user"; | ||
| ownerId: string; | ||
| organizationId?: string; | ||
| permissions: string[]; | ||
| }; | ||
|
|
||
| /** Session shape available to every Notion tool through `ctx.auth`. */ | ||
| export type NotionSession = AuthSession< | ||
| WorkOSMcpClaims, | ||
| NotionApiKeyClaims, | ||
| { | ||
| workosUserId: string; | ||
| workosOrganizationId?: string; | ||
| workosApiKeyId: string; | ||
| workosApiKeyName: string; | ||
| workosApiKeyPermissions: string[]; | ||
| } | ||
| >; | ||
|
|
||
| let workosClient: WorkOS | undefined; | ||
| let warnedMissingWorkosKey = false; | ||
|
|
||
| /** Minimal API key shape returned by WorkOS validation. */ | ||
| type ValidatedWorkosApiKey = { | ||
| id: string; | ||
| owner: { | ||
| type: "organization" | "user"; | ||
| id: string; | ||
| organizationId?: string; | ||
| organization_id?: string; | ||
| }; | ||
| name: string; | ||
| permissions: string[]; | ||
| }; | ||
|
|
||
| const appAuth = auth({ | ||
| resource: mcpResource(), | ||
| authorizationServers: [authKitIssuer()], | ||
| authorizationServers: [metadataOnlyAuthorizationServer()], | ||
| scopes: {}, | ||
| async session(request): Promise<NotionSession | null> { | ||
| const token = request.bearerToken(); | ||
| if (!token) { | ||
| return null; | ||
| } | ||
|
|
||
| const claims = await verifyAuthKitToken(token).catch((error) => { | ||
| console.warn(JSON.stringify({ | ||
| event: "sidecar.notion.auth.invalid_token", | ||
| message: error instanceof Error ? error.message : "Invalid AuthKit token.", | ||
| })); | ||
| return null; | ||
| }); | ||
| if (!claims) { | ||
| const apiKey = await validateWorkosApiKey(token); | ||
| if (!apiKey) { | ||
| return null; | ||
| } | ||
|
|
||
| const workosUserId = subject(claims); | ||
| const workosOrganizationId = organizationId(claims); | ||
|
|
||
| return { | ||
| userId: workosUserId, | ||
| subject: workosUserId, | ||
| clientId: optionalString(claims.client_id), | ||
| scopes: scopesFromClaims(claims), | ||
| expiresAt: expiresAt(claims), | ||
| token, | ||
| claims, | ||
| workosUserId, | ||
| workosOrganizationId, | ||
| }; | ||
| return sessionFromApiKey(token, apiKey); | ||
| }, | ||
| }); | ||
|
|
||
| export default appAuth; | ||
|
|
||
| /** Verifies an AuthKit-issued JWT against the issuer's JWKS endpoint. */ | ||
| async function verifyAuthKitToken(token: string): Promise<WorkOSMcpClaims> { | ||
| const issuer = authKitIssuer(); | ||
| const jwks = createRemoteJWKSet(new URL("/oauth2/jwks", issuer)); | ||
| const { payload } = await jwtVerify(token, jwks, { issuer }); | ||
| return payload as WorkOSMcpClaims; | ||
| } | ||
|
|
||
| /** Returns the MCP resource URL advertised to clients. */ | ||
| function mcpResource(): string { | ||
| return process.env.SIDECAR_MCP_URL ?? "http://127.0.0.1:3101/mcp"; | ||
| } | ||
|
|
||
| /** Returns the AuthKit issuer URL configured in WorkOS. */ | ||
| function authKitIssuer(): string { | ||
| const explicit = process.env.WORKOS_AUTHKIT_ISSUER ?? process.env.AUTHKIT_ISSUER; | ||
| if (explicit) { | ||
| return normalizeIssuer(explicit); | ||
| /** | ||
| * Returns the metadata-only authorization server URL required by Sidecar auth. | ||
| * | ||
| * This example authenticates with WorkOS-managed API keys instead of an OAuth | ||
| * login flow into Sidecar. Clients should send `Authorization: Bearer <api key>` | ||
| * directly and should not attempt OAuth discovery against this URL. | ||
| */ | ||
| function metadataOnlyAuthorizationServer(): string { | ||
| return process.env.SIDECAR_NOTION_AUTH_SERVER ?? "https://sidecar.ai/notion-workos-api-key"; | ||
| } | ||
|
|
||
| /** Validates a bearer token with WorkOS API Keys. */ | ||
| async function validateWorkosApiKey(value: string): Promise<ValidatedWorkosApiKey | null> { | ||
| try { | ||
| const result = await createApiKeyValidation(value); | ||
| return normalizeValidatedApiKey(result.apiKey); | ||
| } catch (error) { | ||
| console.warn(JSON.stringify({ | ||
| event: "sidecar.notion.auth.api_key_validation_failed", | ||
| message: error instanceof Error ? error.message : "WorkOS API key validation failed.", | ||
| })); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| const domain = process.env.WORKOS_AUTHKIT_DOMAIN ?? process.env.AUTHKIT_DOMAIN; | ||
| if (domain) { | ||
| return normalizeIssuer(domain.startsWith("http") ? domain : `https://${domain}`); | ||
| /** Supports both current and older WorkOS SDK validation method names. */ | ||
| async function createApiKeyValidation(value: string): Promise<{ apiKey: unknown }> { | ||
| const apiKeys = workos().apiKeys as { | ||
| validateApiKey?: (payload: { value: string }) => Promise<{ apiKey: unknown }>; | ||
| createValidation?: (payload: { value: string }) => Promise<{ apiKey: unknown }>; | ||
| }; | ||
| const validate = apiKeys.validateApiKey ?? apiKeys.createValidation; | ||
| if (!validate) { | ||
| throw new Error("Installed @workos-inc/node does not expose API key validation."); | ||
| } | ||
|
|
||
| return DEFAULT_AUTHKIT_ISSUER; | ||
| return await validate.call(apiKeys, { value }); | ||
| } | ||
|
|
||
| /** Ensures issuer strings are URL-like and do not carry a trailing slash. */ | ||
| function normalizeIssuer(value: string): string { | ||
| const url = new URL(value); | ||
| if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") { | ||
| throw new Error("WORKOS_AUTHKIT_ISSUER must be https:// except for localhost development."); | ||
| /** Normalizes WorkOS API key validation output across SDK response shapes. */ | ||
| function normalizeValidatedApiKey(value: unknown): ValidatedWorkosApiKey | null { | ||
| if (!isRecord(value)) { | ||
| return null; | ||
| } | ||
| if (typeof value.id !== "string" || typeof value.name !== "string" || !isRecord(value.owner)) { | ||
| return null; | ||
| } | ||
| const ownerType = value.owner.type; | ||
| const ownerId = value.owner.id; | ||
| if ((ownerType !== "organization" && ownerType !== "user") || typeof ownerId !== "string") { | ||
| return null; | ||
| } | ||
|
|
||
| url.pathname = url.pathname.replace(/\/+$/, ""); | ||
| return url.toString().replace(/\/$/, ""); | ||
| return { | ||
| id: value.id, | ||
| name: value.name, | ||
| owner: { | ||
| type: ownerType, | ||
| id: ownerId, | ||
| organizationId: optionalString(value.owner.organizationId) ?? optionalString(value.owner.organization_id), | ||
| }, | ||
| permissions: Array.isArray(value.permissions) | ||
| ? value.permissions.filter((permission): permission is string => typeof permission === "string") | ||
| : [], | ||
| }; | ||
| } | ||
|
|
||
| /** Extracts the authenticated user id from required JWT subject. */ | ||
| function subject(claims: WorkOSMcpClaims): string { | ||
| if (!claims.sub) { | ||
| throw new Error("AuthKit token is missing required subject claim."); | ||
| } | ||
| return claims.sub; | ||
| /** Builds a Sidecar session from a validated WorkOS API key. */ | ||
| function sessionFromApiKey(token: string, apiKey: ValidatedWorkosApiKey): NotionSession { | ||
| const owner = apiKeyOwner(apiKey); | ||
| const claims: NotionApiKeyClaims = { | ||
| sub: owner.userId, | ||
| scope: permissionsScope(apiKey.permissions), | ||
| kind: "workos-api-key", | ||
| apiKeyId: apiKey.id, | ||
| ownerType: apiKey.owner.type, | ||
| ownerId: apiKey.owner.id, | ||
| organizationId: owner.organizationId, | ||
| permissions: apiKey.permissions, | ||
| }; | ||
|
|
||
| return { | ||
| userId: owner.userId, | ||
| subject: owner.userId, | ||
| clientId: apiKey.id, | ||
| scopes: apiKey.permissions, | ||
| token, | ||
| claims, | ||
| workosUserId: owner.userId, | ||
| workosOrganizationId: owner.organizationId, | ||
| workosApiKeyId: apiKey.id, | ||
| workosApiKeyName: apiKey.name, | ||
| workosApiKeyPermissions: apiKey.permissions, | ||
| }; | ||
| } | ||
|
|
||
| /** Extracts a WorkOS organization id when the token contains one. */ | ||
| function organizationId(claims: WorkOSMcpClaims): string | undefined { | ||
| return optionalString(claims.org_id) ?? optionalString(claims.organization_id); | ||
| /** Returns the Vault owner identity represented by a WorkOS API key. */ | ||
| function apiKeyOwner(apiKey: ValidatedWorkosApiKey): { userId: string; organizationId?: string } { | ||
| if (apiKey.owner.type === "organization") { | ||
| return { | ||
| userId: apiKey.owner.id, | ||
| organizationId: apiKey.owner.id, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| userId: apiKey.owner.id, | ||
| organizationId: apiKey.owner.organizationId, | ||
| }; | ||
| } | ||
|
|
||
| /** Converts JWT expiry seconds into a Date for Sidecar's auth context. */ | ||
| function expiresAt(claims: WorkOSMcpClaims): Date | undefined { | ||
| return typeof claims.exp === "number" ? new Date(claims.exp * 1000) : undefined; | ||
| /** Creates the WorkOS SDK client lazily so imports stay cheap. */ | ||
| function workos(): WorkOS { | ||
| workosClient ??= new WorkOS(requiredEnv("WORKOS_API_KEY_NOTION", "WORKOS_API_KEY"), { | ||
| clientId: process.env.WORKOS_CLIENT_ID, | ||
| }); | ||
| return workosClient; | ||
| } | ||
|
|
||
| /** Reads OAuth scopes from either `scope` or `scp` claim conventions. */ | ||
| function scopesFromClaims(claims: WorkOSMcpClaims): string[] { | ||
| if (Array.isArray(claims.scp)) { | ||
| return claims.scp.filter((scope): scope is string => typeof scope === "string" && scope.length > 0); | ||
| /** Reads the first present environment variable from a prioritized list. */ | ||
| function requiredEnv(...names: string[]): string { | ||
| for (const name of names) { | ||
| const value = process.env[name]; | ||
| if (value) { | ||
| return value; | ||
| } | ||
| } | ||
| if (typeof claims.scope === "string") { | ||
| return claims.scope.split(/\s+/).filter(Boolean); | ||
|
|
||
| warnMissingWorkosKeyOnce(names); | ||
| throw new Error(`Set one of ${names.join(", ")} before validating WorkOS API keys.`); | ||
| } | ||
|
|
||
| /** Warns once when the server-side WorkOS key is missing. */ | ||
| function warnMissingWorkosKeyOnce(names: string[]): void { | ||
| if (warnedMissingWorkosKey) { | ||
| return; | ||
| } | ||
| return []; | ||
| warnedMissingWorkosKey = true; | ||
| console.warn(JSON.stringify({ | ||
| event: "sidecar.notion.auth.missing_workos_api_key", | ||
| message: `Set one of ${names.join(", ")} before calling the Notion MCP example.`, | ||
| })); | ||
| } | ||
|
|
||
| /** Formats API key permissions as an OAuth-style scope string for diagnostics. */ | ||
| function permissionsScope(permissions: string[]): string { | ||
| return permissions.length ? permissions.join(" ") : "notion"; | ||
| } | ||
|
|
||
| /** Returns true for non-array objects. */ | ||
| function isRecord(value: unknown): value is Record<string, unknown> { | ||
| return Boolean(value && typeof value === "object" && !Array.isArray(value)); | ||
| } | ||
|
|
||
| /** Returns a string only when the claim is present. */ | ||
| /** Returns a non-empty string value. */ | ||
| function optionalString(value: unknown): string | undefined { | ||
| return typeof value === "string" && value ? value : undefined; | ||
| } |
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
uhuh