TailSpend Docs

OpenAI MCP integration

TailSpend exposes eight structured group/participant/expense tools through an OAuth-only, stateless Streamable HTTP endpoint. OpenAI supplies its own model inference. Tool calls do not invoke TailSpend's expense-chat model or spend chat credits. Existing developer per-user limits, expense-create allowances and usage accounting still apply.

Hosted MCP and Cognito OAuth

The committed stage configuration enables beta only on the next successful deployment. Dev and production remain disabled. This configuration is not evidence of a completed OpenAI connection, a deployed stage or public listing approval. Cognito resource binding on the MCP stage’s Essentials/classic login configuration and the deployed gateway transport must be verified in beta before production enablement.

Request and authorization paths

The developer API keeps its existing /v1 mapping. Its new /v1/mcp route invokes the MCP Lambda, which verifies Cognito-signed access tokens, their issuer, MCP resource audience, dedicated client, expiration, token use and active connection grant. PATs and app ID tokens are rejected.

The REST boundary retains its gateway and accepted-client checks. Both boundaries call executeCoreOperation, sharing the allowlist, scopes, granted groups, strongly consistent membership confirmation, role/ownership checks, quotas, validation, expense operations, idempotency and usage recording. No synthetic trusted authorizer context or internal HTTP call is needed.

Why there is a Cognito adapter

Cognito supplies authentication and signs the access tokens. TailSpend still needs per-connection group consent and immediate application revocation. The MCP Lambda therefore hosts a narrow authorization-code/token adapter:

  1. /oauth/authorize validates the exact OpenAI redirect URL, client, resource, requested scopes, state and PKCE S256 challenge. It creates a ten-minute request and redirects to the existing app's /connect/openai approval page.
  2. The user signs into the app, chooses groups and permissions, and explicitly approves. Writes are unchecked by default. The app API checks current group membership and conditionally creates a pending grant.
  3. The browser continues to Cognito using the same OpenAI PKCE challenge and the adapter's fixed callback. Cognito uses the existing Google/Apple sign-in.
  4. /oauth/callback consumes the approved request, records only a hash of the Cognito code, and redirects that code to the validated OpenAI callback with OpenAI's original state.
  5. /oauth/token accepts the code once, verifies OpenAI's PKCE verifier and original redirect URL, and forwards the exchange to Cognito using the internal callback URL. It cryptographically verifies the returned access token and requires its user to match the account that approved consent.
  6. The adapter atomically binds the Cognito token family to that pending grant, records a hash of the refresh credential and returns Cognito's access/refresh tokens. It does not mint tokens or persist token secrets. Upstream ID tokens are not returned: this facade is OAuth, not a separate OIDC identity provider.
  7. Refresh consumes the prior refresh hash once, requires an active grant, and forwards to Cognito. The newly returned token family/refresh hash remain bound to the same grant ID, including when Cognito rotates either identifier. A lost exchange/refresh response requires reconnection; no unsafe token replay or secret response cache is introduced.

Directly obtaining a token from Cognito does not bypass approval: there is no active token-family binding until the adapter completes step 6. Revoked grants cannot be reactivated by refresh or a new consent grant. /profile/connections lists grants and disconnects them immediately. /oauth/revoke also revokes the application grant and attempts Cognito revocation. On a lost/consumed refresh credential, use the profile's explicit disconnect control.

The adapter publishes its own OAuth metadata and PKCE support. Access tokens are issued by the configured Cognito issuer and verified against that issuer's JWKS. There is no custom JWT signing key or separate OAuth Lambda/database.

Provision a stage

Beta uses OpenAI’s published stable callback, https://chatgpt.com/connector_platform_oauth_redirect. The adapter advertises RFC 9207 issuer identification and returns the exact metadata issuer in iss on successful and error callbacks. Its protected-resource metadata names the same issuer. These are the OpenAI requirements for the stable callback. Confirm that OpenAI’s connection management page selects this URI during beta linking. If it shows a callback-specific URL instead, copy that exact URI into the stage configuration; never insert an invented callback ID or wildcard. Configure a predefined public OAuth client (none token-endpoint authentication), without a client secret, DCR or CIMD.

The CDK contexts accept JSON objects/arrays (or their CLI JSON-string forms):

{
  "mcpOpenAiRedirectUris": {
    "beta": ["https://chatgpt.com/connector_platform_oauth_redirect"]
  },
  "mcpEnabledStages": ["beta"]
}

mcpOpenAiRedirectUris provisions the dedicated Cognito resource server/client in AuthStack. Its callback is the TailSpend adapter /v1/oauth/callback; the exact OpenAI callbacks are enforced by the adapter. The existing app clients and ID-token policies are not broadened.

The normal npm run deploy --workspace=@repo/infra -- --context stage=beta command now encodes initial rollout ordering. For a configured MCP stage it first deploys only Auth (--exclusively), then deploys all stacks with the same configuration. Core cannot read the new client-ID SSM parameter until Auth has created it. Auth already orders after Web/Service/Core, so the exclusive first step is essential for an existing stage. An Auth failure stops deployment. Subsequent deployments preserve the endpoint throughout this step.

The checked-in apps/infra/cdk.json contains beta's stable callback and enablement; the existing GitHub Actions Main Pipeline and manual Deploy workflows use the same deploy command and existing AWS role. No separate AWS credentials are needed in the coding workspace. Production remains unconfigured and disabled. A brand-new stage still needs the normal base-stage bootstrap before this command.

Preserve both contexts in the configuration used by subsequent stage deployments so an unrelated deploy does not remove the client or disable its routes. The runtime also supports MCP_ENABLED=false as an emergency unavailable response (503) on an already-provisioned Lambda. Normal CDK disablement removes MCP public routes/Lambda and leaves the configured Cognito client and stored grants intact.

These commands require the stage's normal AWS/identity-provider deployment setup. Deploy the matching web bundle through the normal stage workflow before testing the browser consent page; the commands above describe infrastructure ordering. MCP client provisioning selects Cognito Essentials for that stage, because its required refresh-token rotation is rejected by Lite. Stages without an MCP client retain Lite; all stages retain classic Hosted UI. This changes the tier of the existing pool in place, without replacing users or disabling rotation.

AWS’s current pricing lists Essentials at $0.015 per monthly active user above the shared 10,000-MAU allowance for eligible direct/social sign-ins. The allowance is shared across the AWS account/organization, not granted anew per stage. The tier applies to all users in the affected pool, including ordinary app users, so include that pool-wide cost in the operating forecast; the chat inference budget does not cover authentication fees. Keep redirect configuration when temporarily disabling MCP so the provisioned rotation-enabled client retains a compatible tier. Keep production out of mcpEnabledStages until beta's complete OAuth and MCP checks pass.

The dedicated client ID appears as OpenAiMcpClientId in the Auth deployment outputs and at /tailspend/<stage>/auth/mcp-client-id in SSM. It is a public client ID, not a key. The exact OpenAI redirect is a client configuration input, not a new AWS gateway.

Configure OpenAI

Setting Beta example
MCP server URL / resource https://developer-api.beta.tailspend.kinesis.studio/v1/mcp
OAuth client Predefined client; ID from SSM above
Token endpoint auth none (public client with PKCE)
Authorization endpoint https://developer-api.beta.tailspend.kinesis.studio/v1/oauth/authorize
Token endpoint https://developer-api.beta.tailspend.kinesis.studio/v1/oauth/token
OAuth metadata issuer https://developer-api.beta.tailspend.kinesis.studio/v1
OIDC Disabled for this OAuth adapter
Scopes MCP resource URL plus /groups:read, /expenses:read, /expenses:create, /expenses:update, /expenses:delete as needed

Only the permissions selected in consent are granted. Tools advertise their required full OAuth scopes. Additional permissions require another consent flow. The exact metadata scopes are available in discovery; do not substitute the app's openid/email scopes or its existing ID token.

Discovery works at both the explicitly advertised metadata URL and RFC-style well-known URLs placed before the resource/issuer path. A second mapping on the same API/domain, .well-known, serves /.well-known/oauth-authorization-server/v1 and /.well-known/oauth-protected-resource/v1/mcp. It is not a second API Gateway.

Tools and behavior

Tool Existing operation
list_groups List approved group memberships
get_group Read a group
list_participants Read participant IDs and names
list_expenses List expenses
get_expense Read expense and version
create_expense Create with caller-supplied idempotency key
update_expense Update with last-read optimistic version
delete_expense Delete after explicit approval

Public protocol requests are bounded at 120 per source IP per minute, and authenticated protocol requests at 30 per connection per minute, in addition to the existing shared weighted developer tool budgets. These initial limits may need tuning for OpenAI egress shared by many users.

Amounts are integer minor units. Use actual participant IDs and explicit cross-currency charges; tools do not invent exchange rates. Each list call reads one page of 10 items by default, at most 25, and returns a cursor. A filtered group page can be empty while still carrying a next cursor. Responses are capped at 512 KiB and request bodies at 64 KiB.

Reuse the same idempotencyKey and body for a logical create, including after a lost response. Keys retain the developer API's 24-hour lifetime and group/body binding. They use the stable grant ID across token refreshes. Updates require version; reconcile conflicts instead of silently retrying an overwrite. Permissions and membership are rechecked for every call. Accurate write and destructive annotations guide client approval but do not replace server policy.

Stateless JSON responses fit buffered REST API Gateway/Lambda operation. GET and DELETE on /mcp do not establish streaming sessions; unsupported methods return 405. No transport state or credentials are shared across warm invocations. Cancellation is checked before execution; once an existing atomic financial operation is admitted, cancellation does not undo it. Resolve uncertain creates through the same idempotency key. Session resumption/SSE are outside this release.

Verification and rollout

Local checks include a real MCP SDK client running initialize → list tools → call through the Lambda adapter, JWT signature/claim rejection, OAuth approval/code/ refresh replay tests, revocation, DynamoDB conditional transaction contracts, shared developer-policy regression, and consent/disconnect UI tests.

The stage integration workflow automatically runs the deployed consent/IAM check for enabled MCP stages, using the callbacks from apps/infra/cdk.json. It invokes no model and gates promotion through the existing integration job. To run it manually:

RUN_MCP_TESTS=true \
MCP_OPENAI_REDIRECT_URI='https://chatgpt.com/connector_platform_oauth_redirect' \
STAGE=beta npm run test:integration --workspace=api-integration-tests -- tests/mcp.test.ts

It needs the existing integration users and AWS/SSM setup. It verifies real metadata, authorization-request creation, group consent, grant listing/revocation and unauthenticated challenges. It does not fake a successful Cognito/OpenAI code exchange or establish live browser compatibility.

Before production, record a real OpenAI connection: sign in → select groups and permissions → discover tools → create/read/update/delete a disposable expense → refresh → disconnect. Verify forbidden groups, demotion/removal, concurrent create replays, stale updates, wrong tokens and cross-user isolation. Verify the browser returns to the intended account after Cognito login. Check costs and native Cognito resource binding. Public listing submission/review is separate.

MCP hosting remains a cost even though model inference is external. CloudWatch McpRequests, McpDurationMs, McpErrors, McpAuthDenials, Lambda duration and the shared developer usage aggregates provide the serving signals. Log payloads contain status/duration/request IDs, not tokens, codes, OAuth state, expense text or financial payloads. No Bedrock invocation or chat-worker permission is granted. Disable a stage by removing it from mcpEnabledStages and redeploying. Revoke a particular connection from the profile without changing infrastructure.

A future /mcp/pat route can use a PAT authorizer and the same MCP handler/shared execution code. No PAT endpoint or local stdio executable is implemented here.

View Markdown source