Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b00480c
docs(mcp): design hosted MCP server
AchoArnold Sep 3, 2026
4c99540
docs(mcp): add implementation plan
AchoArnold Sep 3, 2026
8a5a4c8
feat(api): trust scoped MCP tokens
AchoArnold Sep 3, 2026
97d9786
feat(api): add incoming message endpoint
AchoArnold Sep 3, 2026
bf06243
feat(mcp): add service foundation
AchoArnold Sep 3, 2026
043f66f
fix(mcp): drop firebase SDK, make KeySet config one-shot
AchoArnold Sep 3, 2026
2e17e75
feat(mcp): add OAuth state and metadata
AchoArnold Sep 3, 2026
a748407
fix(mcp): harden OAuth metadata fetching
AchoArnold Sep 3, 2026
dfcf5e2
feat(mcp): add Firebase OAuth flow
AchoArnold Sep 3, 2026
e4ab70f
fix(mcp): harden OAuth authorization flow
AchoArnold Sep 3, 2026
505ecb2
feat(mcp): add httpSMS API client
AchoArnold Sep 3, 2026
5e4d7fc
fix(mcp): redact API query traces
AchoArnold Sep 3, 2026
86afa17
feat(mcp): add messaging tools
AchoArnold Sep 3, 2026
a13bc7d
feat(mcp): add API key tools
AchoArnold Sep 3, 2026
70617c2
fix(mcp): mark rotated keys sensitive
AchoArnold Sep 3, 2026
edec15f
feat(mcp): assemble hosted server
AchoArnold Sep 3, 2026
1786941
fix(mcp): harden server assembly
AchoArnold Sep 3, 2026
1740811
fix(mcp): rate limit rotation prompts
AchoArnold Sep 3, 2026
b980af0
chore(mcp): add Cloud Run deployment
AchoArnold Sep 4, 2026
f69a96f
fix(mcp): clarify deployment defaults
AchoArnold Sep 4, 2026
b95789a
test(mcp): add full integration suite
AchoArnold Sep 4, 2026
3bb6025
fix(tests): make MCP integration deterministic
AchoArnold Sep 4, 2026
ae19eb3
fix(tests): validate rate limit success path
AchoArnold Sep 4, 2026
318be45
ci(mcp): gate deploys on MCP tests
AchoArnold Sep 4, 2026
a44f222
Merge remote-tracking branch 'origin/main' into feat/mcp-server
AchoArnold Sep 4, 2026
3a2391c
fix(auth): bound token metadata caches
AchoArnold Sep 4, 2026
914c11a
refactor(mcp): reuse thread message API
AchoArnold Sep 7, 2026
c2556b3
fix(auth): enable production delegation
AchoArnold Sep 30, 2026
83be26f
fix(mcp): harden OAuth and message reads
AchoArnold Sep 30, 2026
49d24ee
test(mcp): isolate refresh token families
AchoArnold Sep 30, 2026
250fabb
feat(mcp): trace protocol operations
AchoArnold Sep 30, 2026
7cc3e30
fix(mcp): redact transport error queries
AchoArnold Oct 1, 2026
06840e2
test: wait for rate-limit scheduling
AchoArnold Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(mcp): add API key tools
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c
  • Loading branch information
AchoArnold and Copilot committed Sep 3, 2026
commit a13bc7d431702ff685dbaac1d4f8aed74fc1b35e
16 changes: 16 additions & 0 deletions mcp/internal/auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ func PrincipalFromContext(ctx context.Context) (Principal, bool) {
return principal, ok
}

// ClientIDFromContext returns the OAuth client ID carried by the MCP access
// token that mcpauth.RequireBearerToken (configured with a Verifier's
// VerifyMCPToken) has already validated for the current request, or false
// if ctx carries no verified token. Tools use this to bind sensitive
// confirmation state (see the rotate_user_api_key tool) to the exact OAuth
// client that requested the operation, not just the authenticated user.
func ClientIDFromContext(ctx context.Context) (string, bool) {
info := mcpauth.TokenInfoFromContext(ctx)
if info == nil {
return "", false
}

clientID, ok := info.Extra[tokenInfoClientIDKey].(string)
return clientID, ok
}

// RequireScope returns the Principal carried by ctx's already-validated MCP
// access token, or an error if ctx carries no verified token or the token's
// scopes do not include scope. It never calls the httpSMS API and never
Expand Down
10 changes: 10 additions & 0 deletions mcp/internal/auth/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ func newMiddlewareTestServer(t *testing.T, keys *auth.KeySet, requiredScopes []s
principal, ok := auth.PrincipalFromContext(r.Context())
require.True(t, ok, "auth.PrincipalFromContext must find the principal the middleware stored")

clientID, ok := auth.ClientIDFromContext(r.Context())
require.True(t, ok, "auth.ClientIDFromContext must find the client ID the middleware stored")

w.Header().Set("X-Test-User-ID", info.UserID)
w.Header().Set("X-Test-Principal-Email", principal.Email)
w.Header().Set("X-Test-Client-ID", clientID)
w.WriteHeader(http.StatusOK)
})

Expand Down Expand Up @@ -145,6 +149,7 @@ func TestRequireBearerTokenAcceptsValidTokenAndStoresTokenInfo(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, testFirebaseUserID, resp.Header.Get("X-Test-User-ID"))
assert.Equal(t, testUserEmail, resp.Header.Get("X-Test-Principal-Email"))
assert.Equal(t, "client", resp.Header.Get("X-Test-Client-ID"))
}

func doBearerRequest(t *testing.T, url string, token string) *http.Response {
Expand All @@ -164,6 +169,11 @@ func TestPrincipalFromContextReturnsFalseWithoutToken(t *testing.T) {
assert.False(t, ok)
}

func TestClientIDFromContextReturnsFalseWithoutToken(t *testing.T) {
_, ok := auth.ClientIDFromContext(t.Context())
assert.False(t, ok)
}

func TestRequireScopeReturnsErrorWithoutToken(t *testing.T) {
_, err := auth.RequireScope(t.Context(), auth.ScopePhonesRead)
require.Error(t, err)
Expand Down
15 changes: 15 additions & 0 deletions mcp/internal/oauth/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,21 @@ func (s *RedisStore) ConsumeConfirmation(ctx context.Context, handle string) (Co
return record, err
}

// confirmationHandleBytes is the amount of crypto/rand entropy (see
// newRandomToken) encoded into a rotation confirmation handle.
const confirmationHandleBytes = 32

// NewConfirmationHandle returns a new cryptographically random, one-time
// confirmation handle for the primary-API-key-rotation confirmation flow
// (see Confirmation, PutConfirmation, and ConsumeConfirmation). Callers
// store it with PutConfirmation and hand it to the client -- as
// mcp.CallToolResult.RequestState for MRTR-capable clients, or as plain
// tool output text for legacy clients that must echo it back explicitly --
// and later redeem it exactly once with ConsumeConfirmation.
func NewConfirmationHandle() (string, error) {
return newRandomToken(confirmationHandleBytes)
}

// hashedKey returns the namespaced Redis key for publicValue under prefix:
// prefix followed by the hex-encoded SHA-256 hash of publicValue. The raw
// value is never used as key material.
Expand Down
25 changes: 25 additions & 0 deletions mcp/internal/oauth/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,31 @@ func TestRedisStoreGetDynamicClientNotFound(t *testing.T) {
require.ErrorIs(t, err, oauth.ErrNotFound)
}

// TestNewConfirmationHandleIsRandomAndURLSafe asserts NewConfirmationHandle
// returns a fresh, non-empty, URL-safe value on every call (never a fixed
// or predictable value), and that the handle it returns actually works
// end-to-end with PutConfirmation/ConsumeConfirmation.
func TestNewConfirmationHandleIsRandomAndURLSafe(t *testing.T) {
first, err := oauth.NewConfirmationHandle()
require.NoError(t, err)
assert.NotEmpty(t, first)
assert.NotRegexp(t, `[^A-Za-z0-9_-]`, first, "confirmation handle must be URL-safe base64")

second, err := oauth.NewConfirmationHandle()
require.NoError(t, err)
assert.NotEqual(t, first, second, "two generated handles must never collide")

store, _ := newTestStore(t)
ctx := context.Background()

confirmation := oauth.Confirmation{Handle: first, UserID: "firebase-uid", ClientID: "client-id", Operation: "rotate_user_api_key"}
require.NoError(t, store.PutConfirmation(ctx, confirmation, time.Minute))

got, err := store.ConsumeConfirmation(ctx, first)
require.NoError(t, err)
assert.Equal(t, confirmation.UserID, got.UserID)
}

func TestRedisStoreConsumeConfirmationIsOneTimeUse(t *testing.T) {
store, _ := newTestStore(t)
ctx := context.Background()
Expand Down
Loading