Skip to content

fix: resolve userId for user-scoped MCP credentials in playground auth path#2899

Merged
amikofalvy merged 1 commit intomainfrom
fix/playground-user-scoped-credentials
Mar 30, 2026
Merged

fix: resolve userId for user-scoped MCP credentials in playground auth path#2899
amikofalvy merged 1 commit intomainfrom
fix/playground-user-scoped-credentials

Conversation

@amikofalvy
Copy link
Copy Markdown
Collaborator

Summary

  • Bug: The playground "Try it" app fails with invalid_token / 401 for any agent using user-scoped MCP tools (e.g. Notion OAuth). Reproducible across all agents.
  • Root cause: The playground auth path (tryAppCredentialAuth) sets endUserId in execution context metadata, but getUserIdFromContext() only checked metadata.initiatedBy.type === 'user' — which is never set in the app credential auth path. This caused userId to be undefined, breaking user-scoped credential reference lookups in both tool discovery and AgentMcpManager.
  • Fix: getUserIdFromContext now falls back to metadata.endUserId when initiatedBy is not set. This is safe for customer-facing web_client apps too — no credential references exist for external end-user IDs, so the lookup returns undefined gracefully (same behavior as before).

Note: This is a targeted interim fix. A more comprehensive approach would be to normalize the metadata shape across all auth strategies so initiatedBy is always set when a user identity is available.

Test plan

  • Use the "Try it" playground on an agent configured with a user-scoped MCP tool (e.g. Notion) and verify it connects successfully
  • Verify non-playground paths (API keys, Slack, bypass) are unaffected
  • Verify customer-facing web_client apps don't gain unintended credential access

Made with Cursor

…h path

The playground "Try it" auth flows through tryAppCredentialAuth which sets
endUserId in metadata but not initiatedBy. getUserIdFromContext only checked
initiatedBy, causing userId to be undefined for all user-scoped credential
lookups (e.g. Notion OAuth). This broke tool discovery (401) and MCP
connections (invalid_token) for any user-scoped tool used from the playground.

Falls back to metadata.endUserId when initiatedBy is not set.

Made-with: Cursor
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 30, 2026

⚠️ No Changeset found

Latest commit: 45b3b33

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Mar 30, 2026 7:43pm
agents-manage-ui Ready Ready Preview, Comment Mar 30, 2026 7:43pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
agents-docs Skipped Skipped Mar 30, 2026 7:43pm

Request Review

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Mar 30, 2026

TL;DR — Fixes playground "Try it" returning 401 for agents with user-scoped MCP tools (e.g. Notion OAuth). The getUserIdFromContext helper now falls back to endUserId from execution metadata when the initiatedBy field isn't set, which is the case for the playground's app-credential auth path.

Key changes

  • Fall back to endUserId in getUserIdFromContext — when initiatedBy is absent (playground auth path), the function now returns metadata.endUserId instead of undefined, enabling user-scoped credential resolution for MCP tools.

Summary | 2 files | 1 commit | base: mainfix/playground-user-scoped-credentials


Before: getUserIdFromContext only recognized the initiatedBy.type === 'user' shape. The playground auth path sets endUserId instead, so userId resolved to undefined — causing user-scoped MCP credential lookups to fail with invalid_token.
After: The function checks initiatedBy first, then falls back to endUserId. Safe for customer-facing web_client apps because no credential references exist for external end-user IDs, so lookups still return undefined gracefully.

The identical change is applied to both copies of the function — the canonical one in the run domain and its re-export in the top-level types directory.

executionContext.ts · runExecutionContext.ts

Pullfrog  | View workflow run | Triggered by Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(0) Total Issues | Risk: Low


✅ No Blocking Issues Found

The fix correctly addresses the bug where getUserIdFromContext() returns undefined for the playground auth path. The implementation is sound:

  1. Fallback order is correct: initiatedBy.id (internal user) takes precedence over endUserId (app credential auth)
  2. Security is preserved: User-scoped credentials can only be created through the manage domain OAuth flow (requires internal user session). External end-users from web_client apps cannot create credentials, so even if endUserId is returned, no matching credential will exist for lookup.
  3. Both duplicate files updated: The identical change was correctly applied to both domains/run/types/executionContext.ts and types/runExecutionContext.ts

💭 Consider (2) 💭

💭 1) system Duplicate function definition across two files

Issue: getUserIdFromContext is defined identically in two files: agents-api/src/domains/run/types/executionContext.ts and agents-api/src/types/runExecutionContext.ts.

Why: This creates a maintenance burden where changes must be synchronized manually. The PR correctly updated both, but future changes could drift.

Fix: Consider consolidating into a single canonical location (likely domains/run/types/executionContext.ts since it's re-exported from agents-api/src/index.ts) and re-exporting from the other location. This can be a separate follow-up PR.

Refs:

💭 2) system Track the metadata normalization work

Issue: The PR description notes this is an "interim fix" and a more comprehensive approach would normalize the metadata shape across auth strategies so initiatedBy is always set when a user identity is available.

Why: The current design conflates two semantically different identity types (internal platform users vs external end-users) which could lead to confusion in the future.

Fix: Consider creating a tracking issue for the metadata normalization work to ensure it doesn't get lost.

Inline Comments:

  • 🟡 Minor: executionContext.ts:4-7 Docstring doesn't reflect new behavior
  • 🟡 Minor: runExecutionContext.ts:4-7 Docstring also needs update here

✅ APPROVE

Summary: This is a clean, targeted bug fix that correctly resolves the playground authentication issue for user-scoped MCP tools. The fallback logic is sound, security is preserved (external end-users cannot create credentials), and both duplicate function definitions were updated. The inline comments suggest minor docstring updates for accuracy. Ship it! 🚀


Discarded (3)
Location Issue Reason Discarded
executionContext.ts:9-11 Type assertion duplicates canonical type Verified that BaseExecutionContext.metadata in agents-core already includes both initiatedBy and endUserId. However, the assertion is still needed here because ctx.metadata is typed as the broader Record<string, unknown> from the interface. The assertion narrows it appropriately.
system Cross-identity credential exposure risk After tracing the code: credentials are only created through manage domain OAuth (requires internal user session). External end-users cannot create credentials. The lookup key (tenantId, projectId, toolId, userId) would never match for an external endUserId since no credential exists.
system Missing unit tests While tests would be nice, this is a simple fallback logic change. The existing auth middleware tests in runAuth-appCredentialAuth.test.ts already verify endUserId is set correctly in metadata.
Reviewers (3)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-security-iam 2 0 0 0 0 0 2
pr-review-standards 2 0 1 0 0 0 1
pr-review-consistency 3 0 1 0 2 0 0
Total 7 0 2 0 2 0 3

@github-actions github-actions Bot deleted a comment from claude Bot Mar 30, 2026
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct targeted fix. The fallback to metadata.endUserId is safe — getUserScopedCredentialReference queries by (tenantId, projectId, toolId, userId), and no credential references exist for external end-user IDs from customer-facing web_client apps, so the lookup returns undefined gracefully (same as the previous undefined from getUserIdFromContext).

One low-priority observation about the duplicate file.

Pullfrog  | View workflow run | Using Claude Opus𝕏

| undefined;
return metadata?.initiatedBy?.type === 'user' ? metadata.initiatedBy.id : undefined;
if (metadata?.initiatedBy?.type === 'user') return metadata.initiatedBy.id;
return metadata?.endUserId;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getUserIdFromContext in this file is dead code — it is exported but never imported anywhere. The only consumer of this file is runAuth.ts, which imports createBaseExecutionContext only. Consider deleting this function (or the entire file, migrating the one createBaseExecutionContext import in runAuth.ts to ../domains/run/types/executionContext) in a follow-up to avoid maintaining two copies.

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. The fix correctly resolves userId for user-scoped MCP credentials in the playground auth path by falling back to metadata.endUserId. The security invariant holds — no credential references exist for external end-user IDs, so non-playground web_client apps are unaffected.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@amikofalvy amikofalvy added this pull request to the merge queue Mar 30, 2026
Merged via the queue into main with commit 93a9978 Mar 30, 2026
23 of 24 checks passed
@amikofalvy amikofalvy deleted the fix/playground-user-scoped-credentials branch March 30, 2026 17:39
@itoqa
Copy link
Copy Markdown

itoqa Bot commented Mar 30, 2026

Ito Test Report ❌

5 test cases ran. 2 failed, 3 passed.

Overall, 5 test cases were executed in the unified run with 3 passes and 2 failures, while additional exploratory checks reported no confirmed defects or reportable outcomes. The key positives were that Section C regressions passed (non-playground API-key flow remained healthy, missing user identity metadata avoided production crashes, and initiatedBy correctly took precedence over endUserId), but two pre-existing authentication/validation defects were confirmed: a High-severity app isolation gap where mismatched x-inkeep-app-id and JWT could be accepted, and a Medium-severity error-handling bug where tampered scope headers returned 500 instead of a controlled 4xx denial.

❌ Failed (2)
Category Summary Screenshot
Adversarial ⚠️ Mismatched app header/token pair was accepted in asymmetric app auth instead of being rejected. ADV-1
Adversarial 🟠 Unauthorized scope header tampering returned a 500 internal error instead of a controlled 4xx denial. ADV-2
⚠️ Mismatched appId/JWT rejection
  • What failed: A mismatched appId/JWT combination was accepted (200) when the expected behavior is a 4xx rejection tied to app/token binding.
  • Impact: This weakens app isolation in the auth boundary and can let a token validated under one app context be replayed under another app header when keys/claims still verify. It increases the risk of cross-app authorization confusion.
  • Introduced by this PR: No – pre-existing bug (code not changed in this PR)
  • Steps to reproduce:
    1. Obtain a valid playground JWT for app A.
    2. Send POST /run/api/chat with that JWT but set x-inkeep-app-id to app B.
    3. Observe the request is accepted instead of rejected.
  • Stub / mock context: This run used local fixture app identities and temporarily bypassed proof-of-work checks in the run auth paths so adversarial requests could be exercised deterministically in local development. No route interception was used for this case.
  • Code analysis: I inspected the app credential auth flow in runAuth.ts; the asymmetric verification path validates signature and standard claims but does not verify token app binding against x-inkeep-app-id, while the anonymous JWT fallback path does enforce app claim matching.
  • Why this is likely a bug: The code enforces app claim binding only in one token path, leaving the asymmetric path without an equivalent appId consistency check despite handling the same app-scoped trust boundary.

Relevant code:

agents-api/src/middleware/runAuth.ts (lines 532-581)

const verifyOptions: Parameters<typeof jwtVerify>[2] = {
  clockTolerance: 60,
};
if (audience) {
  verifyOptions.audience = audience;
}

let payload: Record<string, unknown>;
try {
  const result = await jwtVerify(bearerToken, cryptoKey, verifyOptions);
  payload = result.payload as Record<string, unknown>;
} catch (err) {
  if (err instanceof errors.JWTExpired) {
    return { ok: false, failureMessage: 'Token expired' };
  }
  ...
}

if (!payload.sub) {
  return { ok: false, failureMessage: 'JWT missing required sub claim' };
}

...

return {
  ok: true,
  endUserId: payload.sub as string,
  kid: matchedKey.kid,
  claims: payload,
};

agents-api/src/middleware/runAuth.ts (lines 783-790)

if (payload.app !== appIdHeader) {
  if (hasAuthConfigured) {
    throw createApiError({ code: 'unauthorized', message: 'Invalid or expired token' });
  }
  return {
    authResult: null,
    failureMessage: 'JWT app claim does not match request app ID',
  };
}
🟠 Header tampering cannot escalate scope
  • What failed: Invalid/tampered scope headers produced a 500 Context validation failed response instead of a controlled client-facing 4xx denial.
  • Impact: Clients receive an internal server error for invalid request scope instead of a deterministic authorization/validation response. This degrades reliability and obscures correct caller remediation behavior.
  • Introduced by this PR: No – pre-existing bug (code not changed in this PR)
  • Steps to reproduce:
    1. Send a valid authenticated run request.
    2. Tamper x-inkeep-tenant-id, x-inkeep-project-id, and x-inkeep-agent-id to unauthorized values.
    3. Observe the server response code.
  • Stub / mock context: The same local run configuration kept proof-of-work checks bypassed in development while sending deliberately unauthorized scope headers to verify denial behavior. The request path itself was exercised directly without response mocking.
  • Code analysis: In contextValidationMiddleware, invalid headers are correctly converted to a bad request error first, but the broad catch block then converts that expected API error into internal_server_error, causing a 500 for client-caused validation failures.
  • Why this is likely a bug: The middleware constructs a client-correct bad_request for invalid headers and then unconditionally masks it to internal_server_error, which is incorrect error classification for a known validation failure.

Relevant code:

agents-api/src/domains/run/context/validation.ts (lines 417-421)

const errorMessage = `Invalid headers: ${validationResult.errors.map((e) => `${e.field}: ${e.message}`).join(', ')}`;
throw createApiError({
  code: 'bad_request',
  message: errorMessage,
});

agents-api/src/domains/run/context/validation.ts (lines 436-446)

} catch (error) {
  logger.error(
    {
      error: error instanceof Error ? error.message : 'Unknown error',
    },
    'Context validation middleware error'
  );
  throw createApiError({
    code: 'internal_server_error',
    message: 'Context validation failed',
  });
}

agents-api/src/domains/run/routes/chatDataStream.ts (lines 99-100)

// Apply context validation middleware
app.use('/chat', contextValidationMiddleware);
✅ Passed (3)
Category Summary Screenshot
Edge Missing user identity metadata path is handled gracefully in source and does not indicate a production metadata-crash defect. EDGE-1
Logic Verified initiatedBy.user.id precedence over endUserId via executable source validation. N/A
Happy-path Direct non-app /run/v1/chat/completions call (no x-inkeep-app-id) returned HTTP 200 SSE output and did not include missing endUserId regression errors. ROUTE-2

Commit: 45b3b33

View Full Run


Tell us how we did: Give Ito Feedback

amikofalvy added a commit that referenced this pull request Mar 30, 2026
…r-user credential

After #2899 correctly resolved userId for playground requests, user-scoped
tools now enter the user-credential lookup path. If no per-user credential
exists, the code previously gave up. This adds an explicit fallback to the
project-level credentialReferenceId, restoring the pre-existing behavior
where tools configured with a shared project credential continued to work.

Made-with: Cursor
amikofalvy added a commit that referenced this pull request Mar 30, 2026
…CP credentials

The app credential auth paths (both authenticated and anonymous web_client) were
setting endUserId but not initiatedBy in the execution context metadata.
getUserIdFromContext checks initiatedBy first, so user-scoped MCP tools (like
Linear OAuth) couldn't resolve credentials — the userId was undefined, causing
the user-scoped credential lookup to be skipped entirely.

This is a follow-up to #2899 which fixed the playground auth path but missed the
app credential paths.

Made-with: Cursor
github-merge-queue Bot pushed a commit that referenced this pull request Mar 30, 2026
…CP credentials (#2912)

* fix: set initiatedBy for app credential auth to resolve user-scoped MCP credentials

The app credential auth paths (both authenticated and anonymous web_client) were
setting endUserId but not initiatedBy in the execution context metadata.
getUserIdFromContext checks initiatedBy first, so user-scoped MCP tools (like
Linear OAuth) couldn't resolve credentials — the userId was undefined, causing
the user-scoped credential lookup to be skipped entirely.

This is a follow-up to #2899 which fixed the playground auth path but missed the
app credential paths.

Made-with: Cursor

* chore: add changeset

Made-with: Cursor
tim-inkeep pushed a commit that referenced this pull request Mar 31, 2026
…CP credentials (#2912)

* fix: set initiatedBy for app credential auth to resolve user-scoped MCP credentials

The app credential auth paths (both authenticated and anonymous web_client) were
setting endUserId but not initiatedBy in the execution context metadata.
getUserIdFromContext checks initiatedBy first, so user-scoped MCP tools (like
Linear OAuth) couldn't resolve credentials — the userId was undefined, causing
the user-scoped credential lookup to be skipped entirely.

This is a follow-up to #2899 which fixed the playground auth path but missed the
app credential paths.

Made-with: Cursor

* chore: add changeset

Made-with: Cursor
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