diff --git a/.github/workflows/release.md b/.github/release-process.md similarity index 51% rename from .github/workflows/release.md rename to .github/release-process.md index 87005a6..41f7727 100644 --- a/.github/workflows/release.md +++ b/.github/release-process.md @@ -10,7 +10,7 @@ The following process is used when publishing new releases to NuGet.org. ## 2. Prepare the release -From a local clone of the repository, use Copilot CLI to invoke the `prepare-release` skill. The skill assesses the semantic version, bumps the version in [`src/Directory.Build.props`](../../src/Directory.Build.props), runs API compatibility checks, reviews documentation, drafts release notes, and creates a pull request with all release artifacts. +From a local clone of the repository, use Copilot CLI to invoke the `prepare-release` skill. The skill assesses the semantic version, bumps the version in [`src/Directory.Build.props`](../src/Directory.Build.props), runs API compatibility checks, reviews documentation, drafts release notes, and creates a pull request with all release artifacts. Review the PR, request changes if needed, and merge when ready. @@ -20,6 +20,15 @@ After the prepare-release PR is merged, invoke the `publish-release` skill. The Review the draft release on GitHub, check 'Set as a pre-release' if appropriate, and click 'Publish release'. +## Branching + +The `main` branch is the next-MAJOR preview and development line; currently, it produces the `2.0.0-preview.*` series. Nightly `cron` CI on `main` publishes CI-suffixed packages to GitHub Packages. +Long-lived `release/{MAJOR}.x` branches are created on demand when a shipped MAJOR needs servicing releases. Every push to a `release/*` branch publishes a CI-suffixed package to GitHub Packages, so servicing CI packages are commit-driven rather than clock-driven. +Short-lived `release-{version}` branches are local prepare-release work branches that become pull requests, such as `release-2.0.0-preview.1` or `release-1.3.1`. +Official NuGet.org publishes occur only when a GitHub Release is created from a branch's tag. +The prepare-release skill asks for the source/base branch first so the release PR targets the same line it assessed. +For the agent-facing, structured version of these rules, see [release-branches.md](skills/shared-resources/release-branches.md). + ## 4. Monitor the Release workflow - After publishing, a workflow will produce build artifacts and publish the NuGet packages to NuGet.org diff --git a/.github/skills/bump-version/SKILL.md b/.github/skills/bump-version/SKILL.md index 29932b4..e789156 100644 --- a/.github/skills/bump-version/SKILL.md +++ b/.github/skills/bump-version/SKILL.md @@ -8,18 +8,21 @@ compatibility: Requires gh CLI with repo access for creating branches and pull r Assess and bump the SDK version in `src/Directory.Build.props` to prepare for the next release. This skill owns the [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) assessment logic — the [SemVer assessment guide](references/semver-assessment.md) is the single source of truth for version assessment criteria used across the release workflow by both the **prepare-release** and **publish-release** skills. +Use the shared [release branch reference](../shared-resources/release-branches.md) for branch roles, previous-release lookup rules, and release work-branch naming. + > **Note**: For comprehensive release preparation — including ApiCompat/ApiDiff, documentation review, and release notes — use the **prepare-release** skill, which incorporates version assessment as part of its broader workflow. ## Process ### Step 1: Read Current Version and Previous Release -Read `src/Directory.Build.props` on the default branch and extract: +Read `src/Directory.Build.props` on the current branch and extract: - `` — the `MAJOR.MINOR.PATCH` version +- `` — the prerelease suffix, when present -Display the current version to the user. +The candidate version is `{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present (for example, `2.0.0-preview.1`). Display the current candidate version to the user. -Determine the previous release tag from `gh release list` (most recent **published** release). Draft releases must be ignored — they represent a pending release that has not yet shipped. Use `--exclude-drafts` or filter to only published releases when querying. +Determine the previous release tag from `gh release list` (most recent **published** release). Draft releases must be ignored — they represent a pending release that has not yet shipped. Use `--exclude-drafts` or filter to only published releases when querying. The lookup is branch-aware: from a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; from `main`, use the most recent published release globally. See [release-branches.md](../shared-resources/release-branches.md) for details. ### Step 2: Assess and Determine Next Version @@ -40,26 +43,31 @@ When context about queued changes is available or can be gathered, assess the ve #### Default Suggestion (Fallback) -When a quick bump is needed without full change analysis, suggest the next **minor** version: +When a quick bump is needed without full change analysis, suggest based on the candidate version: -- Current `1.0.0` → suggest `1.1.0` -- Current `1.2.3` → suggest `1.3.0` +- **Stable candidate** — suggest the next **minor** version: + - Current `1.0.0` → suggest `1.1.0` + - Current `1.2.3` → suggest `1.3.0` +- **Prerelease candidate** — if the suffix starts with an identifier such as `preview.` or `rc.` followed by an integer, suggest incrementing the trailing integer: + - Current `2.0.0-preview.1` → suggest `2.0.0-preview.2` + - Current `2.0.0-rc.1` → suggest `2.0.0-rc.2` Present the suggestion and let the user confirm or provide an alternative. -Parse the confirmed version into its `VersionPrefix` component. +Parse the confirmed version into its `VersionPrefix` and `VersionSuffix` components. Stable versions have no suffix. ### Step 3: Create Pull Request -1. Create a new branch named `bump-version-to-{version}` (e.g. `bump-version-to-1.1.0`) from the default branch +1. Create a new branch named `bump-version-to-{version}` (e.g. `bump-version-to-1.1.0`) from the current branch 2. Update `src/Directory.Build.props`: - - Set `` to the new version + - Set `` to the confirmed stable component + - Set `` for prerelease versions, or clear it for stable versions; add the element if it is missing - Update `` if the MAJOR version has changed 3. Commit with message: `Bump version to {version}` 4. Push the branch and create a pull request: - **Title**: `Bump version to {version}` - **Label**: `infrastructure` - - **Base**: default branch + - **Base**: the current branch (which, for a servicing branch like `release/1.x`, is that servicing branch — not `main`) ### Step 4: Confirm diff --git a/.github/skills/bump-version/references/semver-assessment.md b/.github/skills/bump-version/references/semver-assessment.md index 2d39ec1..f1f5f6d 100644 --- a/.github/skills/bump-version/references/semver-assessment.md +++ b/.github/skills/bump-version/references/semver-assessment.md @@ -56,13 +56,31 @@ Recommend a PATCH version increment if no MAJOR or MINOR criteria are met. - MINOR: `MAJOR.(MINOR+1).0` - PATCH: `MAJOR.MINOR.(PATCH+1)` -**Examples** from previous release `v1.2.0`: +### Prereleases -| Level | Recommended | -|-------|-------------| -| PATCH | `v1.2.1` | -| MINOR | `v1.3.0` | -| MAJOR | `v2.0.0` | +While the candidate version uses a prerelease suffix (e.g., `X.Y.Z-preview.N`, `X.Y.Z-rc.N`), the recommended next version increments the trailing integer of the suffix: `preview.3` → `preview.4`, `rc.1` → `rc.2`. + +Going to GA drops the suffix entirely: `2.0.0-rc.2` → `2.0.0`. + +This is purely about how to *compute* the next version. It does **not** declare any new policy about what kinds of changes are permitted between previews — refer to the existing [versioning documentation](../../../../docs/versioning.html) for breaking-change policy. + +### Branch context + +The "previous release" lookup is constrained to tags matching `v{MAJOR}.*` when assessing from a `release/{MAJOR}.x` servicing branch. On `main`, the lookup is unconstrained (most recent published release globally). + +The MAJOR/MINOR/PATCH classification criteria above are unchanged regardless of branch. + +See [release-branches.md](../../shared-resources/release-branches.md) for branch-role definitions and previous-release lookup rules. + +**Examples**: + +| Previous release | Branch | Level | Recommended | +|--------------------|---------------|-----------------------|------------------------| +| `v1.2.0` | `main` | PATCH | `v1.2.1` | +| `v1.2.0` | `main` | MINOR | `v1.3.0` | +| `v1.2.0` | `main` | MAJOR | `v2.0.0` | +| `v2.0.0-preview.1` | `main` | (prerelease bump) | `v2.0.0-preview.2` | +| `v1.3.0` | `release/1.x` | PATCH | `v1.3.1` | ## Comparing Against the Candidate Version @@ -83,6 +101,7 @@ Present the assessment as a summary table followed by a rationale: | Aspect | Finding | |--------|---------| +| Branch context | release/1.x | | Previous release | v1.0.0 | | Breaking changes | None confirmed | | New API surface | Yes — 3 PRs add new public APIs | diff --git a/.github/skills/prepare-release/SKILL.md b/.github/skills/prepare-release/SKILL.md index 03976ac..f881332 100644 --- a/.github/skills/prepare-release/SKILL.md +++ b/.github/skills/prepare-release/SKILL.md @@ -12,22 +12,39 @@ Prepare a new release for the `modelcontextprotocol/csharp-sdk` repository. This > **User confirmation required: This skill NEVER pushes a branch or creates a pull request without explicit user confirmation.** The user must review and approve all details before any remote operations occur. +Use the shared [release branch reference](../shared-resources/release-branches.md) for branch roles, previous-release lookup rules, and release work-branch naming. + ## Process Work through each step sequentially. Present findings at each step and get user confirmation before proceeding. Skip any step that has no applicable items. -### Step 1: Determine Target and Gather PRs +### Step 1: Select Source Branch + +List candidate source/base branches via: +`gh api repos/{owner}/{repo}/branches --paginate --jq '[.[] | select(.name == "main" or (.name | startswith("release/"))) | .name]'` + +Present the list to the user and ask them to choose the source/base branch. Default selection: `main`. + +The selected branch drives every subsequent step: +1. The branch on which the candidate version is read from `src/Directory.Build.props`. +2. The "previous release" lookup (constrained to `v{MAJOR}.*` on `release/{MAJOR}.x`). +3. The commit range from which PRs are collected. +4. The PR base (`--base`) for `gh pr create` at the end of the skill. + +See [release-branches.md](../shared-resources/release-branches.md) for the structured branch rules. + +### Step 2: Determine Target and Gather PRs The user may provide: -- **A git ref** (commit SHA, branch, or tag) — use as the target commit -- **No context** — show the last 5 commits on `main` (noting HEAD) and offer the option to enter a branch or tag name instead +- **A git ref** (commit SHA, branch, or tag) — use as the target commit relative to the selected source/base branch +- **No context** — show the last 5 commits on the selected source/base branch (noting HEAD) and offer the option to enter a branch or tag name instead Once the target is established: -1. Determine the previous release tag from `gh release list` (most recent **published** release — exclude drafts with `--exclude-drafts`). -2. Get the full list of PRs merged between the previous release tag and the target commit. -3. Read `src/Directory.Build.props` **at the target commit**. Extract `` as the **candidate version**. +1. Determine the previous release tag from `gh release list` (most recent **published** release — exclude drafts with `--exclude-drafts`). Use the selected source/base branch context: on `release/{MAJOR}.x`, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, use the most recent published release globally. +2. Get the full list of PRs merged between the previous release tag and the target commit on the selected branch. +3. Read `src/Directory.Build.props` **at the target commit**. Extract `` and ``; the **candidate version** is `{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present (for example, `2.0.0-preview.1`). -### Step 2: Categorize and Attribute +### Step 3: Categorize and Attribute Sort every PR into one of four categories. See [references/categorization.md](references/categorization.md) for detailed guidance. @@ -50,53 +67,55 @@ Sort every PR into one of four categories. See [references/categorization.md](re - Omit the co-author parenthetical when there are none - Sort entries within each section by merge date (chronological) -### Step 3: Breaking Change Audit +### Step 4: Breaking Change Audit Invoke the **breaking-changes** skill with the commit range from the previous release tag to the target commit. Examine every PR, assess impact, reconcile labels (offering to add/remove labels and comment on PRs), and get user confirmation. Use the results (confirmed breaking changes with impact ordering and detail bullets) in the remaining steps. -### Step 4: Assess Release Version +### Step 5: Assess Release Version -Using the categorized PRs from Step 2 and confirmed breaking changes from Step 3, assess the appropriate [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) release level. Follow the [SemVer assessment guide](../bump-version/references/semver-assessment.md) (owned by the **bump-version** skill) for the full assessment criteria. +Using the categorized PRs from Step 3 and confirmed breaking changes from Step 4, assess the appropriate [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) release level. Follow the [SemVer assessment guide](../bump-version/references/semver-assessment.md) (owned by the **bump-version** skill) for the full assessment criteria. 1. **Classify the release level**: - **MAJOR** — if any confirmed breaking changes are present (API or behavioral), excluding changes to `[Experimental]` APIs - **MINOR** — if no breaking changes but new public APIs, features, or obsoletion warnings are introduced - **PATCH** — otherwise -2. **Compute the recommended version** from the previous release tag: +2. **Compute the recommended version** from the previous release tag and branch context: - Increment the appropriate component (MAJOR resets MINOR.PATCH to 0; MINOR resets PATCH to 0) + - For prerelease candidates such as `preview.N` or `rc.N`, the recommendation may simply increment the trailing integer per the assessment guide 3. **Compare against the candidate version** from `src/Directory.Build.props`. Flag any discrepancy: - **Under-versioned**: The candidate is lower than the recommended level. This is a concern that should be resolved. - **Over-versioned**: The candidate is higher than strictly required. This is acceptable under SemVer but worth noting. 4. **Present the assessment** with a summary table showing the previous release, change classification, recommended level, recommended version, and any discrepancy with the candidate. Include a brief rationale citing the most significant PRs. 5. **Get user confirmation** of the release version before proceeding. -### Step 5: Create Release Branch and Bump Version +### Step 6: Create Release Branch and Bump Version After the version is confirmed: -1. Create a local branch named `release-{version}` from the target commit (e.g., `release-1.1.0`). +1. Create a local branch named `release-{version}` from the target commit (e.g., `release-2.0.0-preview.1`, `release-1.3.1`). 2. Update `src/Directory.Build.props`: - - Set `` to the confirmed version - - Update `` if the MAJOR version has changed (set to the previous release version) + - Set `` to the confirmed stable component + - Set `` for prerelease versions, or clear it for stable versions; add the element if it is missing + - Update `` when appropriate. For the `2.0.0-preview` series, baseline is `1.3.0` (latest shipped 1.x). For subsequent stable releases, baseline is the previous shipped version of the same MAJOR or the latest stable from the previous MAJOR. 3. Build the solution to verify the version change compiles: `dotnet build` This step creates local changes only — nothing is committed or pushed yet. -### Step 6: Run API Compatibility Check +### Step 7: Run API Compatibility Check Run API compatibility validation against the baseline version. Follow [references/apicompat-apidiff.md](references/apicompat-apidiff.md) for the full procedure. 1. Run `dotnet pack` to trigger package validation against `PackageValidationBaselineVersion` 2. Capture the ApiCompat output (compatibility issues, warnings, suppressions) 3. If there are unexpected compatibility breaks: - - Cross-reference with the breaking change audit from Step 3 + - Cross-reference with the breaking change audit from Step 4 - Present any unaccounted breaks to the user - If breaks are intentional, add appropriate entries to `CompatibilitySuppressions.xml` in the affected project directory 4. Record the ApiCompat results for inclusion in the PR description -### Step 7: Generate API Diff Report +### Step 8: Generate API Diff Report Generate a human-readable diff of the public API surface between the previous release and the new version. Follow [references/apicompat-apidiff.md](references/apicompat-apidiff.md) for the full procedure, including how to install the `Microsoft.DotNet.ApiDiff.Tool` from the .NET transport feed. @@ -107,7 +126,7 @@ Generate a human-readable diff of the public API surface between the previous re > **If the ApiDiff tool cannot be installed or fails to produce output, STOP and inform the user.** Present the error and ask how to proceed. Do not fall back to a manual summary — the user must decide whether to troubleshoot, skip the API diff, or abort. -### Step 8: Review and Update Documentation +### Step 9: Review and Update Documentation Review repository documentation for changes needed to compensate for or adapt to this release: @@ -118,12 +137,12 @@ Review repository documentation for changes needed to compensate for or adapt to Stage all documentation changes for inclusion in the release commit. -### Step 9: Draft Release Notes +### Step 10: Draft Release Notes Compose the release notes that will appear in the PR description and serve as the foundation for the **publish-release** skill. This is a draft — the final release notes will be refreshed when the GitHub release is created. 1. **Preamble** — Draft a short paragraph summarizing the release theme. Present it to the user for review and editing. The preamble is **required**. -2. **Breaking Changes** — sorted most → least impactful (from Step 3 results). Include the versioning docs link. +2. **Breaking Changes** — sorted most → least impactful (from Step 4 results). Include the versioning docs link. 3. **What's Changed** — chronological; includes breaking change PRs 4. **Documentation Updates** — chronological 5. **Test Improvements** — chronological @@ -132,11 +151,11 @@ Compose the release notes that will appear in the PR description and serve as th - New contributors (first contribution in this release) - Issue reporters (cite resolving PRs) - PR reviewers (single bullet, sorted by review count, no count shown) -8. **Full Changelog** link +8. **Full Changelog** link using the exact tag, including any suffix (for example, `v1.3.1` or `v2.0.0-preview.1`) -Omit empty sections. Present each section for user review before proceeding. +Omit empty sections. Present each section for user review before proceeding. Tag references in templates use `v{version}` exactly, including prerelease suffixes; the Full Changelog link compares the previous tag to the suffixed tag when applicable. -### Step 10: Commit Changes +### Step 11: Commit Changes Commit all changes to the `release-{version}` branch: @@ -144,39 +163,40 @@ Commit all changes to the `release-{version}` branch: 2. Commit with message: `Prepare release v{version}` 3. Do **not** push yet -### Step 11: Present Release Summary +### Step 12: Present Release Summary -Present **all** of the following details to the user for review. The user must confirm every aspect before proceeding to Step 12. +Present **all** of the following details to the user for review. The user must confirm every aspect before proceeding to Step 13. 1. **Version number** with brief rationale for why this SemVer level was selected -2. **Branch name** (e.g., `release-1.1.0`) -3. **Remote** the branch would be pushed to (show the configured remote, typically `origin`) -4. **Files changed** — list every file modified in the commit with a one-line summary of what changed in each: +2. **Source/base branch** selected in Step 1 +3. **Branch name** (e.g., `release-2.0.0-preview.1`, `release-1.3.1`) +4. **Remote** the branch would be pushed to (show the configured remote, typically `origin`) +5. **Files changed** — list every file modified in the commit with a one-line summary of what changed in each: ``` - src/Directory.Build.props — Version bumped from 1.0.0 to 1.1.0 + src/Directory.Build.props — Version bumped from 2.0.0-preview.1 to 2.0.0-preview.2 src/ModelContextProtocol.Core/CompatibilitySuppressions.xml — Added 2 new suppressions README.md — Updated code sample for new API docs/experimental.md — Added new experimental API reference ``` -5. **Draft release notes** — the complete release notes from Step 9 -6. **API Compatibility results** — the ApiCompat output from Step 6 -7. **API Diff report** — the API diff from Step 7 -8. **Proposed PR title** (e.g., `Release v1.1.0`) -9. **Proposed PR description** — the assembled content combining release notes, ApiCompat, and ApiDiff +6. **Draft release notes** — the complete release notes from Step 10 +7. **API Compatibility results** — the ApiCompat output from Step 7 +8. **API Diff report** — the API diff from Step 8 +9. **Proposed PR title** (e.g., `Release v2.0.0-preview.1`, `Release v1.3.1`) +10. **Proposed PR description** — the assembled content combining release notes, ApiCompat, and ApiDiff After presenting all details, explicitly ask the user: > Would you like to push the branch and create the pull request? **Do not proceed without explicit "yes" confirmation.** -### Step 12: Push Branch and Create Pull Request +### Step 13: Push Branch and Create Pull Request -Only after explicit user confirmation in Step 11: +Only after explicit user confirmation in Step 12: 1. Push the `release-{version}` branch to the remote -2. Create a pull request: +2. Create a pull request with `gh pr create --base {step-1-branch}`: - **Title**: `Release v{version}` - - **Base**: default branch (typically `main`) + - **Base**: the source/base branch selected in Step 1 - **Head**: `release-{version}` - **Description**: The assembled PR description (see PR Description Template below) - **Labels**: Apply appropriate labels (e.g., `release`) @@ -192,18 +212,20 @@ Only after explicit user confirmation in Step 11: - **Single breaking change**: use the same numbered format as multiple - **No user-facing changes**: if all PRs are documentation, tests, or infrastructure, flag that a release may not be warranted and ask the user whether to proceed - **Version discrepancy**: if the candidate version from `Directory.Build.props` doesn't match the SemVer assessment, present the discrepancy and let the user decide the final version +- **Proposed MAJOR does not match branch MAJOR**: if the proposed version's MAJOR doesn't match the branch's MAJOR (for example, proposing `2.0.0-preview.2` on `release/1.x`), flag this as a warning and ask the user to confirm. Do not hard-fail. This is informational, not a policy enforcement. +- **Prerelease bump**: when the candidate version has a suffix like `preview.N`, the SemVer assessment may simply increment `N` rather than computing MAJOR/MINOR/PATCH. Refer to the SemVer assessment guide's Prereleases section. - **No previous release**: if this is the first release, there is no previous tag; gather all PRs merged to the target - **ApiCompat tooling unavailable**: fall back to `dotnet pack` output; note in the PR description that full ApiCompat was run via package validation only - **API diff tool installation fails**: do not fall back to a manual summary; pause and present the installation error to the user, offering options to troubleshoot, skip the API diff section, or abort the release preparation - **No changelogs in repo**: skip changelog updates; note in the summary - **Branch already exists**: if `release-{version}` already exists locally or remotely, ask the user whether to reuse it, delete and recreate, or choose a different name -- **PackageValidationBaselineVersion update**: when bumping MAJOR version, update the baseline to the previous release version; when bumping MINOR or PATCH, keep the existing baseline +- **PackageValidationBaselineVersion update**: for the `2.0.0-preview` series, use `1.3.0`; for subsequent stable releases, use the previous shipped version of the same MAJOR or the latest stable from the previous MAJOR - **CompatibilitySuppressions.xml**: when intentional breaks are found, add suppression entries and include the file in the commit; existing suppressions should be preserved -- **User declines PR creation**: if the user declines at Step 11, leave the local branch intact so they can review, modify, or push manually +- **User declines PR creation**: if the user declines at Step 12, leave the local branch intact so they can review, modify, or push manually ## PR Description Template -The PR description combines release notes, ApiCompat, and ApiDiff into a single document. Omit empty sections. +The PR description combines release notes, ApiCompat, and ApiDiff into a single document. Omit empty sections. The `{version}` placeholder is the full version and may include a prerelease suffix (for example, `Release v2.0.0-preview.1`). ```markdown # Release v{version} @@ -241,7 +263,8 @@ Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/vers * @user made their first contribution in #PR * @user1 @user2 @user3 reviewed pull requests -**Full Changelog**: https://github.com/modelcontextprotocol/csharp-sdk/compare/previous-tag...release-{version} +**Full Changelog**: https://github.com/modelcontextprotocol/csharp-sdk/compare/{previous-tag}...v{version} + --- @@ -266,7 +289,7 @@ Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/vers ## Release Notes Template -The release notes section within the PR description uses the same format as the final GitHub release notes (used by the **publish-release** skill). This ensures consistency between the PR and the published release. +The release notes section within the PR description uses the same format as the final GitHub release notes (used by the **publish-release** skill). This ensures consistency between the PR and the published release. Tag examples such as `v2.0.0-preview.1` are valid and should be used verbatim when the version has a prerelease suffix. Omit empty sections. The preamble is **always required** — it is not inside a section heading. @@ -303,5 +326,6 @@ Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/vers * @user submitted issue #1234 (resolved by #5678) * @user1 @user2 @user3 reviewed pull requests -**Full Changelog**: https://github.com/modelcontextprotocol/csharp-sdk/compare/previous-tag...new-tag +**Full Changelog**: https://github.com/modelcontextprotocol/csharp-sdk/compare/{previous-tag}...v{version} + ``` diff --git a/.github/skills/publish-release/SKILL.md b/.github/skills/publish-release/SKILL.md index fce598c..0706cd3 100644 --- a/.github/skills/publish-release/SKILL.md +++ b/.github/skills/publish-release/SKILL.md @@ -8,6 +8,8 @@ compatibility: Requires gh CLI with repo access and GitHub API access for PR det Create a GitHub release for the `modelcontextprotocol/csharp-sdk` repository after a **prepare-release** PR has been merged. This skill refreshes the release notes to include any PRs merged between the preparation branch point and the merge, warns about changes that affect the version or breaking change assessment, and creates a **draft** GitHub release. +Use the shared [release branch reference](../shared-resources/release-branches.md) for branch roles, previous-release lookup rules, and release work-branch naming. + > **Safety: This skill only creates and updates draft releases. It must never publish a release.** If the user asks to publish, decline and instruct them to publish manually through the GitHub UI. ## Process @@ -18,7 +20,7 @@ Work through each step sequentially. Present findings at each step and get user The user may provide: - **A PR number or URL** — use directly -- **A version number** (e.g., `1.1.0`) — search for a merged PR titled `Release v{version}` +- **A version number** (e.g., `1.1.0`, `2.0.0-preview.1`) — search for a merged PR titled `Release v{version}`. Prerelease versions are used verbatim, for example `Release v2.0.0-preview.1` - **No context** — list recently merged PRs with `Release v` in the title and ask the user to select Verify the PR is merged. Extract: @@ -28,13 +30,13 @@ Verify the PR is merged. Extract: ### Step 2: Determine Version and Commit Range -1. Read `src/Directory.Build.props` at the merge commit to confirm ``. The tag is `v{VersionPrefix}`. -2. Determine the previous release tag from `gh release list` (most recent **published** release — exclude drafts with `--exclude-drafts`). +1. Read `src/Directory.Build.props` at the merge commit to confirm `` and ``. The tag is `v{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present; for example, `2.0.0` + `preview.1` → `v2.0.0-preview.1`. +2. Determine the previous release tag from `gh release list` (most recent **published** release — exclude drafts with `--exclude-drafts`). The lookup is branch-aware: when the merge commit is on a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, use the most recent published release globally. See [release-branches.md](../shared-resources/release-branches.md). 3. Identify the full commit range: previous release tag → merge commit. ### Step 3: Check for Additional PRs -Compare the PRs included in the original prepare-release PR description with the full set of PRs now merged in the commit range. Use the [SemVer assessment guide](../bump-version/references/semver-assessment.md) (owned by the **bump-version** skill) to evaluate the impact of any new PRs against the version that was committed during preparation. +Compare the PRs included in the original prepare-release PR description with the full set of PRs now merged in the commit range. Use the [SemVer assessment guide](../bump-version/references/semver-assessment.md) (owned by the **bump-version** skill) to evaluate the impact of any new PRs against the version that was committed during preparation, including its prerelease and branch-context computation rules. This is not a policy change; only the version computation and previous-release lookup change. 1. Extract the PR list from the prepare-release PR description (all `#NNN` references in release notes sections). 2. Get the full set of PRs merged between the previous release tag and the merge commit. @@ -107,11 +109,11 @@ Follow [references/formatting.md](references/formatting.md) when composing and u ### Step 9: Create Draft Release Display release metadata for user review: -- **Title / Tag**: the confirmed version (e.g. `v1.1.0`) -- **Target**: merge commit SHA, its message, and the prepare-release PR link +- **Title / Tag**: the confirmed tag, including any prerelease suffix (e.g. `v1.3.1`, `v2.0.0-preview.1`) +- **Target**: merge commit SHA, its message, the merge commit's branch (the prepare-release PR base), and the prepare-release PR link After confirmation: -- Create with `gh release create --draft` (always `--draft`) +- Create with `gh release create --draft {tag} --target {merge-commit-branch}` (always `--draft`), using the prerelease tag verbatim when present - **Never publish.** If the user asks to publish, decline and instruct them to publish manually. When the user requests revisions after the initial creation, always rewrite the complete body as a file — never perform in-place string replacements. See [references/formatting.md](references/formatting.md). @@ -131,7 +133,7 @@ When the user requests revisions after the initial creation, always rewrite the ## Release Notes Template -Omit empty sections. The preamble is **always required** — it is not inside a section heading. +Omit empty sections. The preamble is **always required** — it is not inside a section heading. Tags may include prerelease suffixes, such as `v2.0.0-preview.1`, and Full Changelog compare links should use the exact tag. ```markdown [Preamble — REQUIRED. Summarize the release theme.] @@ -166,5 +168,6 @@ Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/vers * @user submitted issue #1234 (resolved by #5678) * @user1 @user2 @user3 reviewed pull requests -**Full Changelog**: https://github.com/modelcontextprotocol/csharp-sdk/compare/previous-tag...new-tag +**Full Changelog**: https://github.com/modelcontextprotocol/csharp-sdk/compare/{previous-tag}...v{version} + ``` diff --git a/.github/skills/shared-resources/release-branches.md b/.github/skills/shared-resources/release-branches.md new file mode 100644 index 0000000..4775677 --- /dev/null +++ b/.github/skills/shared-resources/release-branches.md @@ -0,0 +1,40 @@ +# Release Branches + +Shared reference for release skills. Describes the branch roles used by the release workflow and the rules each skill follows for selecting a branch and looking up the previous release. + +## Branch roles + +| Branch | Purpose | CI behavior | +| ------------------- | ----------------------------------------------- | -------------------------------------- | +| `main` | Next-MAJOR preview/development line | Nightly `cron` build → GitHub Packages | +| `release/{MAJOR}.x` | Long-lived servicing branch for a shipped MAJOR | Every push → GitHub Packages | +| `release-{version}` | Short-lived release preparation branch | Built by PR CI; no package publishing | + +Official NuGet.org publishes happen only when a GitHub Release is created from a branch's tag. + +## Selecting a source/base branch (`prepare-release` Step 1) + +1. List candidate branches via: + `gh api repos/{owner}/{repo}/branches --paginate --jq '[.[] | select(.name == "main" or (.name | startswith("release/"))) | .name]'` +2. Present the list to the user. Default selection: `main`. +3. The selected branch drives: + - Previous-release lookup (see below). + - The branch on which the candidate version is read from `src/Directory.Build.props`. + - The commit range from which PRs are collected. + - The `--base` of the PR created at the end of the skill. + +## Previous-release tag lookup + +- On `main`: most recent published release globally (use `gh release list --exclude-drafts --limit 50` and pick the highest semver). No MAJOR filter. +- On `release/{MAJOR}.x`: most recent published release whose tag matches `v{MAJOR}.*`. Drafts are excluded. + +This is purely a baseline-selection rule. It does **not** change the breaking-change policy. See [the versioning docs](https://csharp.sdk.modelcontextprotocol.io/versioning.html) for the policy. + +## Work-branch naming + +Prepare-release work branches are named `release-{version}` (flat, hyphen-separated): +- `release-2.0.0-preview.1` +- `release-1.3.1` +- `release-2.0.0` + +Hyphens in prerelease versions are valid in git branch names. diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml index e12d00f..4852801 100644 --- a/.github/workflows/ci-code-coverage.yml +++ b/.github/workflows/ci-code-coverage.yml @@ -24,7 +24,7 @@ jobs: pattern: testresults-* - name: Combine coverage reports - uses: danielpalme/ReportGenerator-GitHub-Action@5.5.5 + uses: danielpalme/ReportGenerator-GitHub-Action@7ae927204961589fcb0b0be245c51fbbc87cbca2 # 5.5.5 with: reports: "**/*.cobertura.xml" targetdir: "${{ github.workspace }}/report" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c4842b..eca7a31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,24 @@ # Publish new package versions of ModelContextProtocol # -# Daily and Manual Runs -# - Triggered automatically at 07:00 UTC daily -# - Triggered manually using GitHub Actions workflow_dispatch event -# - Version prefix applied from /src/Directory.Build.props -# - Version suffix set to `ci.{github.run_number}` -# - Package published to GitHub package registry +# Triggers +# - Scheduled (07:00 UTC daily, main branch): produces a CI-suffixed package +# and publishes it to the GitHub package registry. # -# Official Releases -# - Triggered after a GitHub Release is created -# - Version prefix applied from /src/Directory.Build.props -# - Version suffix applied from /src/Directory.Build.props -# - Package published to GitHub package registry -# - Package published to NuGet.org -# - Version prefix and/or suffix should be updated after each release +# - Push to `release/**`: every commit to a release branch produces a CI-suffixed +# package and publishes it to the GitHub package registry. +# +# - Manual `workflow_dispatch` (any branch): same outputs as a scheduled/push build; +# accepts a `version_suffix_override` input. +# +# - GitHub Release published (any branch's tag): publishes the version from +# `src/Directory.Build.props` to the GitHub package registry AND NuGet.org. +# +# Version +# - Version prefix and suffix come from `src/Directory.Build.props`. +# - For non-release triggers, the suffix is replaced with `ci.{run_number}` +# (or `workflow_dispatch.inputs.version_suffix_override` when provided). +# - The prefix and suffix in `Directory.Build.props` should be updated after each +# release using the `bump-version` or `prepare-release` skills. name: Release Publishing @@ -21,6 +26,10 @@ on: schedule: - cron: '0 7 * * *' + push: + branches: + - 'release/**' + workflow_dispatch: inputs: version_suffix_override: diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 1090c53..6d09665 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -1,11 +1,12 @@ - + + + - diff --git a/README.md b/README.md index 7f5a9e1..f0ab4a0 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ For more information about MCP: - [Protocol Specification](https://modelcontextprotocol.io/specification/) - [GitHub Organization](https://github.com/modelcontextprotocol) +## Cross-Application Access (Identity Assertion Authorization Grant flow) + +The SDK provides support for the [Identity Assertion Authorization Grant flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx) +via `IdentityAssertionGrantProvider`. See the [Cross-Application Access](docs/concepts/transports/transports.md#cross-application-access) section in the transport docs for full usage details. + ## License This project is licensed under the [Apache License 2.0](LICENSE). diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index c0b571f..91f86db 100644 --- a/docs/concepts/tasks/tasks.md +++ b/docs/concepts/tasks/tasks.md @@ -13,7 +13,7 @@ uid: tasks The Model Context Protocol (MCP) supports [task-based execution] for long-running operations. Tasks enable a "call-now, fetch-later" pattern where clients can initiate operations that may take significant time to complete, then poll for status and retrieve results when ready. -[task-based execution]: https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks +[task-based execution]: https://modelcontextprotocol.io/seps/1686-tasks ## Overview @@ -601,4 +601,4 @@ While this file-based approach demonstrates the pattern, production systems shou - - - -- [MCP Tasks Specification](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks) +- [MCP Tasks Specification](https://modelcontextprotocol.io/seps/1686-tasks) diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index d0d8851..a3dda4d 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -39,11 +39,63 @@ Key properties: | `Command` | The executable to launch (required) | | `Arguments` | Command-line arguments for the process | | `WorkingDirectory` | Working directory for the server process | -| `EnvironmentVariables` | Environment variables (merged with current; `null` values remove variables) | +| `EnvironmentVariables` | Environment variables (merged with current when inheriting; `null` values remove variables) | +| `InheritEnvironmentVariables` | Whether the server process inherits the current process's environment variables (default: `true`) | | `ShutdownTimeout` | Graceful shutdown timeout (default: 5 seconds) | | `StandardErrorLines` | Callback for stderr output from the server process | | `Name` | Optional transport identifier for logging | +#### Environment variable inheritance + +By default, the server process inherits **all** environment variables from the current process. This includes credentials, tokens, proxy settings, and internal configuration that may be sensitive or irrelevant to the server. When running third-party or untrusted MCP servers, consider disabling inheritance to prevent unintentional credential leakage: + +```csharp +var transport = new StdioClientTransport(new StdioClientTransportOptions +{ + Command = "my-mcp-server", + InheritEnvironmentVariables = false, + EnvironmentVariables = StdioClientTransportOptions.GetDefaultEnvironmentVariables(), +}); +``` + +`GetDefaultEnvironmentVariables()` returns a curated set of environment variables (such as `PATH`, `HOME`, and standard system directories) that most child processes need to start correctly, without leaking credentials or other sensitive values from the parent process. The allowlist is aligned with the defaults used by the TypeScript and Python MCP SDKs. On Windows it also includes `PATHEXT`, which is required for the OS to recognize `.cmd` and `.bat` files as executable. You can add server-specific variables on top: + +```csharp +var env = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); +env["MY_SERVER_API_KEY"] = apiKey; + +var transport = new StdioClientTransport(new StdioClientTransportOptions +{ + Command = "my-mcp-server", + InheritEnvironmentVariables = false, + EnvironmentVariables = env, +}); +``` + +If you need to selectively forward a specific set of variables from the parent environment rather than using the curated allowlist, build the dictionary manually: + +```csharp +var env = new Dictionary(); +foreach (var name in new[] { "PATH", "HOME", "HTTP_PROXY", "HTTPS_PROXY" }) +{ + var value = Environment.GetEnvironmentVariable(name); + if (value is not null) + env[name] = value; +} + +var transport = new StdioClientTransport(new StdioClientTransportOptions +{ + Command = "my-mcp-server", + InheritEnvironmentVariables = false, + EnvironmentVariables = env, +}); +``` + +> [!WARNING] +> **Security risk (inheriting):** Variables such as `AWS_SECRET_ACCESS_KEY`, `GITHUB_TOKEN`, `OPENAI_API_KEY`, and similar credentials present in the parent process automatically flow into the child process unless inheritance is disabled. This can unintentionally expose sensitive values to third-party or untrusted MCP servers. +> +> **Compatibility risk (not inheriting):** Disabling inheritance can cause the child process to fail to start or behave incorrectly if it relies on variables provided by the OS or shell. `GetDefaultEnvironmentVariables()` covers the most common requirements — `PATH`, `HOME`, and standard system directories — so for most servers it is a safe starting point. For servers that need additional variables not in the default set (such as `DOTNET_ROOT`, `LD_LIBRARY_PATH`, `JAVA_HOME`, or proxy settings like `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`), add them on top as shown in the example above. + #### stdio server Use for servers that communicate over stdin/stdout: @@ -326,3 +378,42 @@ Console.WriteLine(await echo.InvokeAsync(new() { ["arg"] = "Hello World" })); ``` Like [stdio](#stdio-transport), the in-memory transport is inherently single-session — there is no `Mcp-Session-Id` header, and server-to-client requests (sampling, elicitation, roots) work naturally over the bidirectional pipe. This makes it ideal for testing servers that depend on these features. See [Sessions](xref:stateless) for how session behavior varies across transports. + +## Cross-Application Access + +The SDK provides built-in support for the [Identity Assertion Authorization Grant (ID-JAG) flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx) via `IdentityAssertionGrantProvider`. This enables non-interactive enterprise SSO scenarios where users authenticate once via their enterprise Identity Provider (IdP) and access MCP servers without per-server authorization prompts. + +The flow consists of two steps: +1. **RFC 8693 Token Exchange** at the enterprise IdP: OIDC ID token → JWT Authorization Grant (JAG) +2. **RFC 7523 JWT Bearer Grant** at the MCP authorization server: JAG → access token + +### Usage + +```csharp +using ModelContextProtocol.Authentication; + +// The caller owns the HttpClient lifetime. +var httpClient = new HttpClient(); + +var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "mcp-client-id", + IdpTokenEndpoint = "https://company.okta.com/oauth2/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (context, cancellationToken) => + // Fetch a fresh ID token from your SSO session. + mySsoClient.GetIdTokenAsync(cancellationToken) + }, + httpClient); + +var tokens = await provider.GetAccessTokenAsync( + resourceUrl: new Uri("https://mcp-server.example.com"), + authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"), + cancellationToken: ct); + +// Use tokens.AccessToken to authenticate against the MCP server. +// Call provider.InvalidateCache() to force a fresh token exchange on the next call. +``` + +The provider caches the resulting access token and reuses it until it expires. To force re-authentication (e.g. after a 401 response), call `provider.InvalidateCache()` before retrying. diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 5154728..0ea6d74 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -23,7 +23,7 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T | Diagnostic ID | Description | | :------------ | :---------- | -| `MCPEXP001` | Experimental APIs for features in the MCP specification itself, including Tasks and Extensions. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [MCP Tasks specification](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). | +| `MCPEXP001` | Experimental APIs for features in the MCP specification itself, including Tasks and Extensions. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [MCP Tasks specification](https://modelcontextprotocol.io/seps/1686-tasks)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). | | `MCPEXP002` | Experimental SDK APIs unrelated to the MCP specification itself, including subclassing `McpClient`/`McpServer` (see [#1363](https://github.com/modelcontextprotocol/csharp-sdk/pull/1363)) and `RunSessionHandler`, which may be removed or change signatures in a future release (consider using `ConfigureSessionOptions` instead). | ## Obsolete APIs diff --git a/samples/ChatWithTools/Program.cs b/samples/ChatWithTools/Program.cs index c5870cd..1499bc0 100644 --- a/samples/ChatWithTools/Program.cs +++ b/samples/ChatWithTools/Program.cs @@ -39,6 +39,8 @@ var mcpClient = await McpClient.CreateAsync( Command = "npx", Arguments = ["-y", "--verbose", "@modelcontextprotocol/server-everything"], Name = "Everything", + InheritEnvironmentVariables = false, + EnvironmentVariables = StdioClientTransportOptions.GetDefaultEnvironmentVariables(), }), clientOptions: new() { @@ -82,4 +84,4 @@ while (true) Console.WriteLine(); messages.AddMessages(updates); -} \ No newline at end of file +} diff --git a/samples/QuickstartClient/Program.cs b/samples/QuickstartClient/Program.cs index 685d8ac..28927cf 100644 --- a/samples/QuickstartClient/Program.cs +++ b/samples/QuickstartClient/Program.cs @@ -31,6 +31,8 @@ else Name = "Demo Server", Command = command, Arguments = arguments, + InheritEnvironmentVariables = false, + EnvironmentVariables = GetMinimalDotNetEnvironment(), }); } await using var mcpClient = await McpClient.CreateAsync(clientTransport!); @@ -122,3 +124,21 @@ static string GetCurrentSourceDirectory([CallerFilePath] string? currentFile = n Debug.Assert(!string.IsNullOrWhiteSpace(currentFile)); return Path.GetDirectoryName(currentFile) ?? throw new InvalidOperationException("Unable to determine source directory."); } + +// Returns the safe default environment variables plus extras needed by 'dotnet run'. +// Omitting variables the server doesn't need prevents unintentional leakage of +// credentials or other sensitive values present in the parent process. +static Dictionary GetMinimalDotNetEnvironment() +{ + var env = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); + // 'dotnet run' also needs DOTNET_ROOT and NUGET_PACKAGES to find the .NET runtime and package cache. + foreach (var key in (string[])["DOTNET_ROOT", "NUGET_PACKAGES"]) + { + var value = Environment.GetEnvironmentVariable(key); + if (value is not null) + { + env[key] = value; + } + } + return env; +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 21be76a..1ff7404 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ https://csharp.sdk.modelcontextprotocol.io https://github.com/modelcontextprotocol/csharp-sdk git - 1.3.0 + 1.4.0 ModelContextProtocol © Model Context Protocol a Series of LF Projects, LLC. ModelContextProtocol;mcp;ai;llm diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index ec28eff..cb17ab1 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -209,7 +209,20 @@ internal sealed class StreamableHttpHandler( } var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); - if (sessionManager.TryRemove(sessionId, out var session)) + if (string.IsNullOrEmpty(sessionId) || !sessionManager.TryGetValue(sessionId, out var session)) + { + return; + } + + if (!session.HasSameUserId(context.User)) + { + await WriteJsonRpcErrorAsync(context, + "Forbidden: The currently authenticated user does not match the user who initiated the session.", + StatusCodes.Status403Forbidden); + return; + } + + if (sessionManager.TryRemove(sessionId, out session)) { await session.DisposeAsync(); } diff --git a/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs new file mode 100644 index 0000000..9dd440b --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs @@ -0,0 +1,32 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Options for exchanging a JWT Authorization Grant for an access token via RFC 7523. +/// +internal sealed class ExchangeJwtBearerGrantOptions +{ + /// + /// Gets or sets the MCP Server's authorization server token endpoint URL. + /// + public required string TokenEndpoint { get; set; } + + /// + /// Gets or sets the JWT Authorization Grant (JAG) assertion obtained from token exchange. + /// + public required string Assertion { get; set; } + + /// + /// Gets or sets the client ID for authentication with the MCP authorization server. + /// + public required string ClientId { get; set; } + + /// + /// Gets or sets the client secret for authentication with the MCP authorization server. Optional. + /// + public string? ClientSecret { get; set; } + + /// + /// Gets or sets the scopes to request (space-separated). Optional. + /// + public string? Scope { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs new file mode 100644 index 0000000..c949037 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs @@ -0,0 +1,296 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace ModelContextProtocol.Authentication; + +/// +/// Provides internal utilities for the Cross-Application Access authorization flow. +/// +/// +/// Implements the Enterprise Managed Authorization flow as specified at +/// . +/// +internal static class IdentityAssertionGrant +{ + #region Constants + + /// Grant type URN for RFC 8693 token exchange. + public const string GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"; + + /// Grant type URN for RFC 7523 JWT Bearer authorization grant. + public const string GrantTypeJwtBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + + /// Token type URN for OpenID Connect ID Tokens (RFC 8693). + public const string TokenTypeIdToken = "urn:ietf:params:oauth:token-type:id_token"; + + /// Token type URN for SAML 2.0 assertions (RFC 8693). + public const string TokenTypeSaml2 = "urn:ietf:params:oauth:token-type:saml2"; + + /// + /// Token type URN for Identity Assertion JWT Authorization Grants. + /// As specified at + /// . + /// + public const string TokenTypeIdJag = "urn:ietf:params:oauth:token-type:id-jag"; + + /// + /// The expected value for token_type in a JAG token exchange response per RFC 8693 §2.2.1. + /// The issued token is not an OAuth access token, so its type is "N_A". + /// + public const string TokenTypeNotApplicable = "N_A"; + + #endregion + + #region Token Exchange (RFC 8693) + + /// + /// Requests a JWT Authorization Grant (JAG) from an Identity Provider via RFC 8693 Token Exchange. + /// Returns the JAG string to be used as a JWT Bearer assertion (RFC 7523) against the MCP authorization server. + /// + public static async Task RequestJwtAuthorizationGrantAsync( + RequestJwtAuthGrantOptions options, + HttpClient httpClient, + CancellationToken cancellationToken = default) + { + Throw.IfNull(options); + Throw.IfNullOrWhiteSpace(options.TokenEndpoint); + Throw.IfNullOrWhiteSpace(options.Audience); + Throw.IfNullOrWhiteSpace(options.Resource); + Throw.IfNullOrWhiteSpace(options.IdToken); + Throw.IfNullOrWhiteSpace(options.ClientId); + + var formData = new Dictionary + { + ["grant_type"] = GrantTypeTokenExchange, + ["requested_token_type"] = TokenTypeIdJag, + ["subject_token"] = options.IdToken, + ["subject_token_type"] = TokenTypeIdToken, + ["audience"] = options.Audience, + ["resource"] = options.Resource, + ["client_id"] = options.ClientId, + }; + + if (!string.IsNullOrEmpty(options.ClientSecret)) + { + formData["client_secret"] = options.ClientSecret!; + } + + if (!string.IsNullOrEmpty(options.Scope)) + { + formData["scope"] = options.Scope!; + } + + using var requestContent = new FormUrlEncodedContent(formData); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint) + { + Content = requestContent + }; + + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + var responseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (!httpResponse.IsSuccessStatusCode) + { + OAuthErrorResponse? errorResponse = null; + try + { + errorResponse = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.OAuthErrorResponse); + } + catch + { + // Could not parse error response + } + + throw new IdentityAssertionGrantException( + $"Token exchange failed with status {(int)httpResponse.StatusCode}.", + errorResponse?.Error, + errorResponse?.ErrorDescription, + errorResponse?.ErrorUri); + } + + var response = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.JagTokenExchangeResponse); + + if (response is null) + { + var ex = new IdentityAssertionGrantException("Failed to parse token exchange response."); + ex.Data["ResponseBody"] = responseBody; + throw ex; + } + + if (string.IsNullOrEmpty(response.AccessToken)) + { + throw new IdentityAssertionGrantException("Token exchange response missing required field: access_token"); + } + + if (!string.Equals(response.IssuedTokenType, TokenTypeIdJag, StringComparison.Ordinal)) + { + throw new IdentityAssertionGrantException( + $"Token exchange response issued_token_type must be '{TokenTypeIdJag}', got '{response.IssuedTokenType}'."); + } + + if (!string.Equals(response.TokenType, TokenTypeNotApplicable, StringComparison.Ordinal)) + { + throw new IdentityAssertionGrantException( + $"Token exchange response token_type must be '{TokenTypeNotApplicable}' per RFC 8693 §2.2.1, got '{response.TokenType}'."); + } + + return response.AccessToken; + } + + #endregion + + #region JWT Bearer Grant (RFC 7523) + + /// + /// Exchanges a JWT Authorization Grant (JAG) for an access token at an MCP Server's authorization server + /// using the JWT Bearer grant (RFC 7523). + /// + public static async Task ExchangeJwtBearerGrantAsync( + ExchangeJwtBearerGrantOptions options, + HttpClient httpClient, + CancellationToken cancellationToken = default) + { + Throw.IfNull(options); + Throw.IfNullOrWhiteSpace(options.TokenEndpoint); + Throw.IfNullOrWhiteSpace(options.Assertion); + Throw.IfNullOrWhiteSpace(options.ClientId); + + var formData = new Dictionary + { + ["grant_type"] = GrantTypeJwtBearer, + ["assertion"] = options.Assertion, + ["client_id"] = options.ClientId, + }; + + if (!string.IsNullOrEmpty(options.ClientSecret)) + { + formData["client_secret"] = options.ClientSecret!; + } + + if (!string.IsNullOrEmpty(options.Scope)) + { + formData["scope"] = options.Scope!; + } + + using var requestContent = new FormUrlEncodedContent(formData); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint) + { + Content = requestContent + }; + + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + var responseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (!httpResponse.IsSuccessStatusCode) + { + OAuthErrorResponse? errorResponse = null; + try + { + errorResponse = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.OAuthErrorResponse); + } + catch + { + // Could not parse error response + } + + throw new IdentityAssertionGrantException( + $"JWT bearer grant failed with status {(int)httpResponse.StatusCode}.", + errorResponse?.Error, + errorResponse?.ErrorDescription, + errorResponse?.ErrorUri); + } + + var response = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.JwtBearerAccessTokenResponse); + + if (response is null) + { + var ex = new IdentityAssertionGrantException("Failed to parse JWT bearer grant response."); + ex.Data["ResponseBody"] = responseBody; + throw ex; + } + + if (string.IsNullOrEmpty(response.AccessToken)) + { + throw new IdentityAssertionGrantException("JWT bearer grant response missing required field: access_token"); + } + + if (string.IsNullOrEmpty(response.TokenType)) + { + throw new IdentityAssertionGrantException("JWT bearer grant response missing required field: token_type"); + } + + if (!string.Equals(response.TokenType, "bearer", StringComparison.OrdinalIgnoreCase)) + { + throw new IdentityAssertionGrantException( + $"JWT bearer grant response token_type must be 'bearer' per RFC 7523, got '{response.TokenType}'."); + } + + return new TokenContainer + { + AccessToken = response.AccessToken, + TokenType = response.TokenType, + RefreshToken = response.RefreshToken, + ExpiresIn = response.ExpiresIn, + Scope = response.Scope, + ObtainedAt = DateTimeOffset.UtcNow, + }; + } + + #endregion + + #region Helper: Auth Server Metadata Discovery + + private static readonly string[] s_wellKnownPaths = [".well-known/openid-configuration", ".well-known/oauth-authorization-server"]; + + /// + /// Discovers authorization server metadata from the well-known endpoints. + /// + internal static async Task DiscoverAuthServerMetadataAsync( + Uri issuerUrl, + HttpClient httpClient, + CancellationToken cancellationToken) + { + var baseUrl = issuerUrl.ToString(); + if (!baseUrl.EndsWith("/", StringComparison.Ordinal)) + { + issuerUrl = new Uri($"{baseUrl}/"); + } + + foreach (var path in s_wellKnownPaths) + { + try + { + var wellKnownEndpoint = new Uri(issuerUrl, path); + var response = await httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + continue; + } + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var metadata = await JsonSerializer.DeserializeAsync( + stream, + McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata, + cancellationToken).ConfigureAwait(false); + + if (metadata is not null) + { + return metadata; + } + } + catch + { + continue; + } + } + + throw new IdentityAssertionGrantException($"Failed to discover authorization server metadata for: {issuerUrl}"); + } + + #endregion +} diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantContext.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantContext.cs new file mode 100644 index 0000000..2b956b9 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantContext.cs @@ -0,0 +1,20 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Context provided to the for a Cross-Application Access +/// authorization flow. Contains the URLs discovered during the OAuth flow needed for the token exchange step. +/// +public sealed class IdentityAssertionGrantContext +{ + /// + /// Gets the MCP resource server URL (i.e., the resource parameter for token exchange). + /// This is the URL of the MCP server being accessed. + /// + public required Uri ResourceUrl { get; init; } + + /// + /// Gets the MCP authorization server URL (i.e., the audience parameter for token exchange). + /// This is the URL of the authorization server protecting the MCP resource. + /// + public required Uri AuthorizationServerUrl { get; init; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantException.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantException.cs new file mode 100644 index 0000000..3dcec80 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantException.cs @@ -0,0 +1,51 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Represents an error that occurred during a Cross-Application Access authorization operation +/// (token exchange per RFC 8693, and JWT bearer grant per RFC 7523). +/// +public sealed class IdentityAssertionGrantException : Exception +{ + /// + /// Gets the OAuth error code, if available (e.g., "invalid_request", "invalid_grant"). + /// + public string? ErrorCode { get; } + + /// + /// Gets the human-readable error description from the OAuth error response. + /// + public string? ErrorDescription { get; } + + /// + /// Gets the URI identifying a human-readable web page with error information. + /// + public string? ErrorUri { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The OAuth error code. + /// The human-readable error description. + /// The error URI. + public IdentityAssertionGrantException(string message, string? errorCode = null, string? errorDescription = null, string? errorUri = null) + : base(FormatMessage(message, errorCode, errorDescription)) + { + ErrorCode = errorCode; + ErrorDescription = errorDescription; + ErrorUri = errorUri; + } + + private static string FormatMessage(string message, string? errorCode, string? errorDescription) + { + if (!string.IsNullOrEmpty(errorCode)) + { + message = $"{message} Error: {errorCode}"; + if (!string.IsNullOrEmpty(errorDescription)) + { + message = $"{message} ({errorDescription})"; + } + } + return message; + } +} diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantIdTokenCallback.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantIdTokenCallback.cs new file mode 100644 index 0000000..2951d1e --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantIdTokenCallback.cs @@ -0,0 +1,17 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Represents a method that returns an OIDC ID token for use in a Cross-Application Access authorization flow. +/// +/// +/// Context containing the MCP resource and authorization server URLs discovered during the OAuth flow. +/// +/// The to monitor for cancellation requests. +/// +/// A task that represents the asynchronous operation. The task result contains the OIDC ID token string +/// obtained from the enterprise Identity Provider (e.g., via SSO login). The provider will then use this +/// ID token to perform the RFC 8693 token exchange to obtain a JWT Authorization Grant. +/// +public delegate Task IdentityAssertionGrantIdTokenCallback( + IdentityAssertionGrantContext context, + CancellationToken cancellationToken); diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs new file mode 100644 index 0000000..359c66f --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs @@ -0,0 +1,207 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ModelContextProtocol.Authentication; + +/// +/// Provides Cross-Application Access authorization as a standalone, non-interactive provider +/// that can be used alongside the MCP client's OAuth infrastructure. +/// +/// +/// +/// This provider implements the full Identity Assertion Authorization Grant flow as specified at +/// : +/// +/// +/// +/// The is called to obtain an OIDC ID token. +/// It receives a with the discovered resource and authorization +/// server URLs. +/// +/// +/// The provider performs the RFC 8693 token exchange at the enterprise Identity Provider +/// (using the configured IdpTokenEndpoint or discovered from IdpUrl), +/// exchanging the ID token for a JWT Authorization Grant (JAG). +/// +/// +/// The JAG is then exchanged for an access token at the MCP Server's authorization server +/// via the RFC 7523 JWT Bearer grant. +/// +/// +/// +/// +/// +/// var provider = new IdentityAssertionGrantProvider( +/// new IdentityAssertionGrantProviderOptions +/// { +/// ClientId = "mcp-client-id", +/// IdpTokenEndpoint = "https://company.okta.com/oauth2/token", +/// IdpClientId = "idp-client-id", +/// IdTokenCallback = (context, ct) => +/// mySsoClient.GetIdTokenAsync(ct) +/// }, +/// httpClient: myHttpClient); +/// +/// var tokens = await provider.GetAccessTokenAsync( +/// resourceUrl: new Uri("https://mcp-server.example.com"), +/// authorizationServerUrl: new Uri("https://auth.example.com"), +/// cancellationToken: ct); +/// +/// +public sealed class IdentityAssertionGrantProvider +{ + private readonly IdentityAssertionGrantProviderOptions _options; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + private TokenContainer? _cachedTokens; + + /// + /// Initializes a new instance of the class. + /// + /// Configuration for the Cross-Application Access provider. + /// + /// The HTTP client to use for token exchange requests. The caller is responsible for the lifetime of this instance. + /// + /// Optional logger factory. + /// or is null. + /// Required option values are missing. + public IdentityAssertionGrantProvider( + IdentityAssertionGrantProviderOptions options, + HttpClient httpClient, + ILoggerFactory? loggerFactory = null) + { + Throw.IfNull(options); + Throw.IfNull(httpClient); + + Throw.IfNullOrWhiteSpace(options.ClientId); + Throw.IfNullOrWhiteSpace(options.IdpClientId); + + if (string.IsNullOrEmpty(options.IdpUrl) && string.IsNullOrEmpty(options.IdpTokenEndpoint)) + { + throw new ArgumentException("Either IdpUrl or IdpTokenEndpoint is required.", $"{nameof(options)}.{nameof(options.IdpUrl)}"); + } + + if (options.IdTokenCallback is null) + { + throw new ArgumentNullException($"{nameof(options)}.{nameof(options.IdTokenCallback)}"); + } + + _options = options; + _httpClient = httpClient; + _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; + } + + /// + /// Performs the full Cross-Application Access flow to obtain an access token for the given MCP resource. + /// + /// The MCP resource server URL. + /// The MCP authorization server URL. + /// The to monitor for cancellation requests. + /// A containing the access token. + /// Thrown when any step of the flow fails. + public async Task GetAccessTokenAsync( + Uri resourceUrl, + Uri authorizationServerUrl, + CancellationToken cancellationToken = default) + { + // Return cached token if still valid + if (_cachedTokens is not null && !_cachedTokens.IsExpired) + { + return _cachedTokens; + } + + _logger.LogDebug("Starting Cross-Application Access flow for resource {ResourceUrl}", resourceUrl); + + // Step 1: Discover MCP authorization server metadata to find the token endpoint + var mcpAuthMetadata = await IdentityAssertionGrant.DiscoverAuthServerMetadataAsync( + authorizationServerUrl, _httpClient, cancellationToken).ConfigureAwait(false); + + var mcpTokenEndpoint = mcpAuthMetadata.TokenEndpoint?.ToString() + ?? throw new IdentityAssertionGrantException( + $"MCP authorization server metadata at {authorizationServerUrl} missing token_endpoint."); + + // Step 2: Call the ID token callback to get the caller's OIDC ID token + var context = new IdentityAssertionGrantContext + { + ResourceUrl = resourceUrl, + AuthorizationServerUrl = authorizationServerUrl, + }; + + _logger.LogDebug("Requesting ID token via callback"); + var idToken = await _options.IdTokenCallback(context, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrEmpty(idToken)) + { + throw new IdentityAssertionGrantException("ID token callback returned a null or empty token."); + } + + // Step 3: RFC 8693 token exchange — ID token → JWT Authorization Grant (JAG) at the enterprise IdP + _logger.LogDebug("Performing RFC 8693 token exchange at IdP"); + var idpTokenEndpoint = await ResolveIdpTokenEndpointAsync(cancellationToken).ConfigureAwait(false); + + var jag = await IdentityAssertionGrant.RequestJwtAuthorizationGrantAsync( + new RequestJwtAuthGrantOptions + { + TokenEndpoint = idpTokenEndpoint, + Audience = authorizationServerUrl.ToString(), + Resource = resourceUrl.ToString(), + IdToken = idToken, + ClientId = _options.IdpClientId, + ClientSecret = _options.IdpClientSecret, + Scope = _options.IdpScope, + }, _httpClient, cancellationToken).ConfigureAwait(false); + + // Step 4: RFC 7523 JWT bearer grant — JAG → access token at the MCP authorization server + _logger.LogDebug("Exchanging JAG for access token at {McpTokenEndpoint}", mcpTokenEndpoint); + var tokens = await IdentityAssertionGrant.ExchangeJwtBearerGrantAsync( + new ExchangeJwtBearerGrantOptions + { + TokenEndpoint = mcpTokenEndpoint, + Assertion = jag, + ClientId = _options.ClientId, + ClientSecret = _options.ClientSecret, + Scope = _options.Scope, + }, _httpClient, cancellationToken).ConfigureAwait(false); + + _cachedTokens = tokens; + _logger.LogDebug("Cross-Application Access flow completed successfully"); + + return tokens; + } + + /// + /// Clears any cached tokens, forcing a fresh token exchange on the next call to . + /// + public void InvalidateCache() + { + _cachedTokens = null; + } + + private string? _resolvedIdpTokenEndpoint; + + private async Task ResolveIdpTokenEndpointAsync(CancellationToken cancellationToken) + { + if (_resolvedIdpTokenEndpoint is not null) + { + return _resolvedIdpTokenEndpoint; + } + + if (!string.IsNullOrEmpty(_options.IdpTokenEndpoint)) + { + _resolvedIdpTokenEndpoint = _options.IdpTokenEndpoint!; + return _resolvedIdpTokenEndpoint; + } + + // Discover from IdpUrl + var idpMetadata = await IdentityAssertionGrant.DiscoverAuthServerMetadataAsync( + new Uri(_options.IdpUrl!), _httpClient, cancellationToken).ConfigureAwait(false); + + var resolved = idpMetadata.TokenEndpoint?.ToString() + ?? throw new IdentityAssertionGrantException( + $"IdP metadata discovery for {_options.IdpUrl} did not return a token_endpoint."); + + _resolvedIdpTokenEndpoint = resolved; + return resolved; + } +} diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs new file mode 100644 index 0000000..c6cc7f8 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs @@ -0,0 +1,68 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Configuration options for the . +/// +public sealed class IdentityAssertionGrantProviderOptions +{ + /// + /// Gets or sets the MCP client ID used for the JWT Bearer grant (RFC 7523) at the MCP authorization server. + /// + public required string ClientId { get; set; } + + /// + /// Gets or sets the MCP client secret used for the JWT Bearer grant at the MCP authorization server. + /// Optional; only required if the MCP authorization server requires client authentication. + /// + public string? ClientSecret { get; set; } + + /// + /// Gets or sets the scopes to request from the MCP authorization server (space-separated). Optional. + /// + public string? Scope { get; set; } + + /// + /// Gets or sets the enterprise Identity Provider base URL for OAuth/OIDC metadata discovery. + /// Used to discover IdpTokenEndpoint automatically when is not set. + /// Either this or must be provided. + /// + public string? IdpUrl { get; set; } + + /// + /// Gets or sets the enterprise Identity Provider token endpoint URL for RFC 8693 token exchange. + /// When provided, skips IdP metadata discovery. Either this or must be provided. + /// + public string? IdpTokenEndpoint { get; set; } + + /// + /// Gets or sets the client ID for authentication with the enterprise Identity Provider (RFC 8693 token exchange). + /// + public required string IdpClientId { get; set; } + + /// + /// Gets or sets the client secret for authentication with the enterprise Identity Provider. Optional. + /// + public string? IdpClientSecret { get; set; } + + /// + /// Gets or sets the scopes to request from the enterprise Identity Provider (space-separated). Optional. + /// + public string? IdpScope { get; set; } + + /// + /// Gets or sets the callback that supplies the OIDC ID token for the Cross-Application Access flow. + /// + /// + /// + /// This callback is invoked after the MCP resource and authorization server URLs have been discovered. + /// It receives a with these URLs and should return the + /// OIDC ID token string obtained from the enterprise Identity Provider (e.g., from an SSO login session). + /// + /// + /// The provider will use the returned ID token to internally perform the RFC 8693 token exchange at the + /// configured IdP, obtaining a JWT Authorization Grant, which is then exchanged for an access token at + /// the MCP authorization server via RFC 7523. + /// + /// + public required IdentityAssertionGrantIdTokenCallback IdTokenCallback { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/JagTokenExchangeResponse.cs b/src/ModelContextProtocol.Core/Authentication/JagTokenExchangeResponse.cs new file mode 100644 index 0000000..35a08f6 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/JagTokenExchangeResponse.cs @@ -0,0 +1,40 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Represents the response from an RFC 8693 Token Exchange for the JAG flow. +/// Contains the JWT Authorization Grant in the field. +/// +internal sealed class JagTokenExchangeResponse +{ + /// + /// Gets or sets the issued JAG. Despite the name "access_token" (required by RFC 8693), + /// this contains a JAG JWT, not an OAuth access token. + /// + [System.Text.Json.Serialization.JsonPropertyName("access_token")] + public string AccessToken { get; set; } = null!; + + /// + /// Gets or sets the type of the security token issued. + /// This MUST be . + /// + [System.Text.Json.Serialization.JsonPropertyName("issued_token_type")] + public string IssuedTokenType { get; set; } = null!; + + /// + /// Gets or sets the token type. This MUST be "N_A" per RFC 8693 §2.2.1. + /// + [System.Text.Json.Serialization.JsonPropertyName("token_type")] + public string TokenType { get; set; } = null!; + + /// + /// Gets or sets the scope of the issued token, if different from the request. + /// + [System.Text.Json.Serialization.JsonPropertyName("scope")] + public string? Scope { get; set; } + + /// + /// Gets or sets the lifetime in seconds of the issued token. + /// + [System.Text.Json.Serialization.JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/JwtBearerAccessTokenResponse.cs b/src/ModelContextProtocol.Core/Authentication/JwtBearerAccessTokenResponse.cs new file mode 100644 index 0000000..9a0a400 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/JwtBearerAccessTokenResponse.cs @@ -0,0 +1,37 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Represents the response from a JWT Bearer grant (RFC 7523) access token request. +/// +internal sealed class JwtBearerAccessTokenResponse +{ + /// + /// Gets or sets the OAuth access token. + /// + [System.Text.Json.Serialization.JsonPropertyName("access_token")] + public string AccessToken { get; set; } = null!; + + /// + /// Gets or sets the token type. This should be "Bearer". + /// + [System.Text.Json.Serialization.JsonPropertyName("token_type")] + public string TokenType { get; set; } = null!; + + /// + /// Gets or sets the lifetime in seconds of the access token. + /// + [System.Text.Json.Serialization.JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } + + /// + /// Gets or sets the refresh token. + /// + [System.Text.Json.Serialization.JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + + /// + /// Gets or sets the scope of the access token. + /// + [System.Text.Json.Serialization.JsonPropertyName("scope")] + public string? Scope { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/OAuthErrorResponse.cs b/src/ModelContextProtocol.Core/Authentication/OAuthErrorResponse.cs new file mode 100644 index 0000000..a8822fa --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/OAuthErrorResponse.cs @@ -0,0 +1,26 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Represents an OAuth error response per RFC 6749 Section 5.2. +/// Used for both token exchange and JWT bearer grant error responses. +/// +internal sealed class OAuthErrorResponse +{ + /// + /// Gets or sets the error code. + /// + [System.Text.Json.Serialization.JsonPropertyName("error")] + public string? Error { get; set; } + + /// + /// Gets or sets the human-readable error description. + /// + [System.Text.Json.Serialization.JsonPropertyName("error_description")] + public string? ErrorDescription { get; set; } + + /// + /// Gets or sets the URI identifying a human-readable web page with error information. + /// + [System.Text.Json.Serialization.JsonPropertyName("error_uri")] + public string? ErrorUri { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs b/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs new file mode 100644 index 0000000..7e83b19 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs @@ -0,0 +1,42 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Options for requesting a JWT Authorization Grant from an Identity Provider via RFC 8693 Token Exchange. +/// +internal sealed class RequestJwtAuthGrantOptions +{ + /// + /// Gets or sets the IDP's token endpoint URL. + /// + public required string TokenEndpoint { get; set; } + + /// + /// Gets or sets the MCP authorization server URL (used as the audience parameter). + /// + public required string Audience { get; set; } + + /// + /// Gets or sets the MCP resource server URL (used as the resource parameter). + /// + public required string Resource { get; set; } + + /// + /// Gets or sets the OIDC ID token to exchange. + /// + public required string IdToken { get; set; } + + /// + /// Gets or sets the client ID for authentication with the IDP. + /// + public required string ClientId { get; set; } + + /// + /// Gets or sets the client secret for authentication with the IDP. Optional. + /// + public string? ClientSecret { get; set; } + + /// + /// Gets or sets the scopes to request (space-separated). Optional. + /// + public string? Scope { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs index 24a7dba..b0af062 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs @@ -111,6 +111,11 @@ public sealed partial class StdioClientTransport : IClientTransport #endif } + if (!_options.InheritEnvironmentVariables) + { + startInfo.Environment.Clear(); + } + if (_options.EnvironmentVariables != null) { foreach (var entry in _options.EnvironmentVariables) @@ -121,9 +126,8 @@ public sealed partial class StdioClientTransport : IClientTransport if (logger.IsEnabled(LogLevel.Trace)) { - LogCreateProcessForTransportSensitive(logger, endpointName, _options.Command, + LogCreateProcessForTransportDetailed(logger, endpointName, _options.Command, startInfo.Arguments, - string.Join(", ", startInfo.Environment.Select(kvp => $"{kvp.Key}={kvp.Value}")), startInfo.WorkingDirectory); } else @@ -295,8 +299,8 @@ public sealed partial class StdioClientTransport : IClientTransport [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} starting server process. Command: '{Command}'.")] private static partial void LogCreateProcessForTransport(ILogger logger, string endpointName, string command); - [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} starting server process. Command: '{Command}', Arguments: {Arguments}, Environment: {Environment}, Working directory: {WorkingDirectory}.")] - private static partial void LogCreateProcessForTransportSensitive(ILogger logger, string endpointName, string command, string? arguments, string environment, string workingDirectory); + [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} starting server process. Command: '{Command}', Arguments: {Arguments}, Working directory: {WorkingDirectory}.")] + private static partial void LogCreateProcessForTransportDetailed(ILogger logger, string endpointName, string command, string? arguments, string workingDirectory); [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} failed to start server process.")] private static partial void LogTransportProcessStartFailed(ILogger logger, string endpointName); diff --git a/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs b/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs index 2d6df08..6fe171d 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs @@ -1,3 +1,5 @@ +using System.Runtime.InteropServices; + namespace ModelContextProtocol.Client; /// @@ -5,6 +7,90 @@ namespace ModelContextProtocol.Client; /// public sealed class StdioClientTransportOptions { + // Platform-appropriate allowlists, aligned with the TypeScript and Python MCP SDK defaults. + // TypeScript adds PROGRAMFILES; Python adds PATHEXT. Both are included here. + private static readonly string[] s_defaultWindowsVars = + [ + "APPDATA", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "PATH", "PATHEXT", + "PROCESSOR_ARCHITECTURE", "PROGRAMFILES", "SYSTEMDRIVE", "SYSTEMROOT", + "TEMP", "USERNAME", "USERPROFILE", + ]; + + private static readonly string[] s_defaultUnixVars = + [ + "HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER", + ]; + + /// + /// Returns a curated set of environment variables from the current process that are safe to forward to a child + /// MCP server process. + /// + /// + /// A new populated with the subset of the current process's environment + /// variables that most child processes need to start correctly — for example PATH, HOME, and + /// standard system directories. Values that appear to be shell function definitions (those starting with + /// ()) are excluded for security reasons. + /// + /// + /// + /// The allowlist is aligned with the defaults used by the TypeScript and Python MCP SDKs. On Windows it + /// includes: APPDATA, HOMEDRIVE, HOMEPATH, LOCALAPPDATA, PATH, + /// PATHEXT, PROCESSOR_ARCHITECTURE, PROGRAMFILES, SYSTEMDRIVE, + /// SYSTEMROOT, TEMP, USERNAME, and USERPROFILE. On Unix/macOS it includes: + /// HOME, LOGNAME, PATH, SHELL, TERM, and USER. + /// + /// + /// This method is designed to be used together with set to + /// . Pass the returned dictionary as , optionally + /// adding any server-specific variables the server requires: + /// + /// var env = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); + /// env["MY_SERVER_API_KEY"] = apiKey; + /// + /// var transport = new StdioClientTransport(new StdioClientTransportOptions + /// { + /// Command = "my-mcp-server", + /// InheritEnvironmentVariables = false, + /// EnvironmentVariables = env, + /// }); + /// + /// + /// + /// If the server requires additional variables not in the default set (such as DOTNET_ROOT, + /// JAVA_HOME, or proxy settings), add them explicitly after calling this method. + /// + /// + public static Dictionary GetDefaultEnvironmentVariables() + { + var names = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? s_defaultWindowsVars + : s_defaultUnixVars; + + var result = new Dictionary( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal); + foreach (var name in names) + { + var value = Environment.GetEnvironmentVariable(name); + if (value is null) + { + continue; + } + + if (value.StartsWith("()", StringComparison.Ordinal)) + { + // Skip shell function definitions — they are a security risk. + continue; + } + + result[name] = value; + } + + return result; + } + + /// /// Gets or sets the command to execute to start the server process. /// @@ -38,6 +124,44 @@ public sealed class StdioClientTransportOptions /// public string? WorkingDirectory { get; set; } + /// + /// Gets or sets a value indicating whether the server process should inherit the current process's environment variables. + /// + /// + /// to inherit the current process's environment variables (the default); + /// to start the server process with an empty environment and only the variables explicitly provided via + /// . + /// + /// + /// + /// When (the default), the server process starts with all of the current process's environment + /// variables. Any entries in are then applied on top, adding or overwriting inherited + /// variables. + /// + /// + /// When , the server process starts with a completely empty environment. The + /// dictionary is the sole source of environment variables for the child process. This is useful when you want to minimize + /// the attack surface by preventing credentials, tokens, proxy settings, and other sensitive values present in the current + /// environment from unintentionally reaching the child process. + /// + /// + /// Security consideration: Inheriting environment variables (the default) can unintentionally expose + /// sensitive values to the child process. Variables such as AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, + /// OPENAI_API_KEY, and similar credentials that are present in the parent process will automatically flow into + /// the server process, which may be undesirable when running third-party or untrusted MCP servers. + /// + /// + /// Compatibility consideration: Disabling inheritance can cause the child process to fail to start or + /// behave unexpectedly if it relies on variables provided by the operating system or the user's shell environment. + /// covers the most common requirements — PATH, HOME, and + /// standard system directories — and is a safe starting point for most servers. For servers that also need variables + /// outside that set (such as DOTNET_ROOT, LD_LIBRARY_PATH, JAVA_HOME, or proxy settings like + /// HTTP_PROXY, HTTPS_PROXY, and NO_PROXY), add them explicitly via + /// after calling . + /// + /// + public bool InheritEnvironmentVariables { get; set; } = true; + /// /// Gets or sets environment variables to set for the server process. /// @@ -48,10 +172,14 @@ public sealed class StdioClientTransportOptions /// to the server without modifying its code. /// /// - /// By default, when starting the server process, the server process will inherit the current environment's variables, - /// as discovered via . After those variables are found, the entries - /// in this dictionary are used to augment and overwrite the entries read from the environment. - /// That includes removing the variables for any of this collection's entries with a null value. + /// When is (the default), the server process starts with + /// all environment variables inherited from the current process. The entries in this + /// dictionary are then applied on top: adding new variables, overwriting inherited ones, or removing variables whose + /// value is set to . + /// + /// + /// When is , the server process starts with an empty + /// environment. This dictionary is the sole source of environment variables for the child process. /// /// public IDictionary? EnvironmentVariables { get; set; } diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index abb6d29..70eb30d 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -187,6 +187,12 @@ public static partial class McpJsonUtilities [JsonSerializable(typeof(DynamicClientRegistrationRequest))] [JsonSerializable(typeof(DynamicClientRegistrationResponse))] + // For Enterprise Managed Authorization flow as specified at + // https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx + [JsonSerializable(typeof(JagTokenExchangeResponse))] + [JsonSerializable(typeof(JwtBearerAccessTokenResponse))] + [JsonSerializable(typeof(OAuthErrorResponse))] + // Primitive types for use in consuming AIFunctions [JsonSerializable(typeof(string))] [JsonSerializable(typeof(byte))] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 4f2d5aa..40b9e82 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -8,6 +8,7 @@ using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; using System.Collections.Concurrent; using System.Net; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; @@ -868,4 +869,76 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT }); } } + + [Fact] + public async Task DeleteRequest_FromDifferentUser_IsRejected_AndSessionSurvives() + { + Assert.SkipWhen(Stateless, "Sessions don't exist in stateless mode."); + + Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); + Builder.Services.AddHttpContextAccessor(); + + await using var app = Builder.Build(); + + // Pick the user from a test header so different HttpClient requests can act as different users. + app.Use(next => async context => + { + var name = context.Request.Headers["X-Test-User"].ToString(); + if (!string.IsNullOrEmpty(name)) + { + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)], + "TestAuthType", "name", "role")); + } + await next(context); + }); + + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + const string initializeRequest = """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}} + """; + + using var initRequest = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/") + { + Content = new StringContent(initializeRequest, System.Text.Encoding.UTF8, "application/json"), + }; + initRequest.Headers.Add("X-Test-User", "Alice"); + initRequest.Headers.Accept.ParseAdd("application/json"); + initRequest.Headers.Accept.ParseAdd("text/event-stream"); + + using var initResponse = await HttpClient.SendAsync(initRequest, TestContext.Current.CancellationToken); + Assert.True(initResponse.IsSuccessStatusCode); + var sessionId = Assert.Single(initResponse.Headers.GetValues("Mcp-Session-Id")); + + // A DELETE from a different authenticated user must not be able to tear down Alice's session. + using var bobDelete = new HttpRequestMessage(HttpMethod.Delete, "http://localhost:5000/"); + bobDelete.Headers.Add("X-Test-User", "Bob"); + bobDelete.Headers.Add("Mcp-Session-Id", sessionId); + using var bobDeleteResponse = await HttpClient.SendAsync(bobDelete, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Forbidden, bobDeleteResponse.StatusCode); + + // Alice should still be able to use the session. + const string toolCallRequest = """ + {"jsonrpc":"2.0","id":2,"method":"tools/list"} + """; + using var aliceCall = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/") + { + Content = new StringContent(toolCallRequest, System.Text.Encoding.UTF8, "application/json"), + }; + aliceCall.Headers.Add("X-Test-User", "Alice"); + aliceCall.Headers.Add("Mcp-Session-Id", sessionId); + aliceCall.Headers.Accept.ParseAdd("application/json"); + aliceCall.Headers.Accept.ParseAdd("text/event-stream"); + using var aliceCallResponse = await HttpClient.SendAsync(aliceCall, TestContext.Current.CancellationToken); + Assert.True(aliceCallResponse.IsSuccessStatusCode); + + // Alice can still terminate her own session. + using var aliceDelete = new HttpRequestMessage(HttpMethod.Delete, "http://localhost:5000/"); + aliceDelete.Headers.Add("X-Test-User", "Alice"); + aliceDelete.Headers.Add("Mcp-Session-Id", sessionId); + using var aliceDeleteResponse = await HttpClient.SendAsync(aliceDelete, TestContext.Current.CancellationToken); + Assert.True(aliceDeleteResponse.IsSuccessStatusCode); + } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/IdentityAssertionGrantIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/IdentityAssertionGrantIntegrationTests.cs new file mode 100644 index 0000000..6972fa4 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/IdentityAssertionGrantIntegrationTests.cs @@ -0,0 +1,167 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; +using System.Net.Http.Headers; + +namespace ModelContextProtocol.AspNetCore.Tests.OAuth; + +/// +/// Integration tests for Cross-Application Access authorization using the in-memory +/// test OAuth server as a stand-in for both the enterprise Identity Provider (IdP) and +/// the MCP Authorization Server (AS). +/// +/// Flow exercised: +/// 1. discovers the MCP AS +/// metadata and calls the ID token callback. +/// 2. The provider performs RFC 8693 token exchange at /idp/token on the test OAuth server +/// (ID token → JAG). +/// 3. The provider exchanges the JAG for an access token at /token +/// (RFC 7523 JWT-bearer grant: JAG → access token). +/// 4. The access token is passed to the MCP client transport and used to authenticate +/// against the protected MCP server. +/// +public class IdentityAssertionGrantIntegrationTests : OAuthTestBase +{ + public IdentityAssertionGrantIntegrationTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + [Fact] + public async Task CanAuthenticate_WithIdentityAssertionGrantProvider() + { + // Enable Enterprise Managed Authorization endpoints on the test OAuth server. + TestOAuthServer.EnterpriseSupportEnabled = true; + + await using var app = await StartMcpServerAsync(); + + // Simulate the enterprise ID token that would normally come from the SSO login step. + const string simulatedIdToken = "test-enterprise-sso-id-token"; + + // Create the provider with IdP config folded into options. + // The ID token callback just returns the SSO ID token; the provider performs + // RFC 8693 (ID token → JAG) and RFC 7523 (JAG → access token) internally. + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "enterprise-mcp-client", + ClientSecret = "enterprise-mcp-secret", + IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token", + IdpClientId = "enterprise-idp-client", + IdpClientSecret = "enterprise-idp-secret", + IdTokenCallback = (_, ct) => Task.FromResult(simulatedIdToken), + }, + httpClient: HttpClient); + + // Run the full Cross-Application Access flow: discover AS → get JAG → exchange for access token. + var tokens = await provider.GetAccessTokenAsync( + resourceUrl: new Uri(McpServerUrl), + authorizationServerUrl: new Uri(OAuthServerUrl), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(tokens.AccessToken); + Assert.False(string.IsNullOrEmpty(tokens.AccessToken)); + Assert.Equal("bearer", tokens.TokenType, ignoreCase: true); + + // Wire the obtained access token into an HTTP client that shares the same + // in-memory Kestrel transport as the rest of the test fixture. + var mcpHttpClient = new HttpClient(SocketsHttpHandler, disposeHandler: false); + ConfigureHttpClient(mcpHttpClient); + mcpHttpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + + // Connect the MCP client using the enterprise access token — no interactive OAuth flow. + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new Uri(McpServerUrl) }, + mcpHttpClient, + LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // If we get here the MCP server accepted the enterprise access token. + Assert.NotNull(client); + } + + [Fact] + public async Task IdentityAssertionGrantProvider_ReturnsCachedToken_OnSecondCall() + { + TestOAuthServer.EnterpriseSupportEnabled = true; + + await using var _ = await StartMcpServerAsync(); + + var idTokenCallCount = 0; + + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "enterprise-mcp-client", + ClientSecret = "enterprise-mcp-secret", + IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token", + IdpClientId = "enterprise-idp-client", + IdpClientSecret = "enterprise-idp-secret", + IdTokenCallback = (_, ct) => + { + idTokenCallCount++; + return Task.FromResult("test-sso-token"); + }, + }, + httpClient: HttpClient); + + var tokens1 = await provider.GetAccessTokenAsync( + new Uri(McpServerUrl), new Uri(OAuthServerUrl), + TestContext.Current.CancellationToken); + + var tokens2 = await provider.GetAccessTokenAsync( + new Uri(McpServerUrl), new Uri(OAuthServerUrl), + TestContext.Current.CancellationToken); + + // The ID token callback (and therefore the IdP round-trip) should only fire once. + Assert.Equal(1, idTokenCallCount); + Assert.Equal(tokens1.AccessToken, tokens2.AccessToken); + } + + [Fact] + public async Task IdentityAssertionGrantProvider_FetchesFreshToken_AfterInvalidateCache() + { + TestOAuthServer.EnterpriseSupportEnabled = true; + + await using var _ = await StartMcpServerAsync(); + + var idTokenCallCount2 = 0; + + var provider2 = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "enterprise-mcp-client", + ClientSecret = "enterprise-mcp-secret", + IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token", + IdpClientId = "enterprise-idp-client", + IdpClientSecret = "enterprise-idp-secret", + IdTokenCallback = (_, ct) => + { + idTokenCallCount2++; + return Task.FromResult("test-sso-token"); + }, + }, + httpClient: HttpClient); + + var tokens1 = await provider2.GetAccessTokenAsync( + new Uri(McpServerUrl), new Uri(OAuthServerUrl), + TestContext.Current.CancellationToken); + + // Invalidate the cache to force a full re-exchange. + provider2.InvalidateCache(); + + var tokens2 = await provider2.GetAccessTokenAsync( + new Uri(McpServerUrl), new Uri(OAuthServerUrl), + TestContext.Current.CancellationToken); + + // The IdP should have been called twice — once for each GetAccessTokenAsync after invalidation. + Assert.Equal(2, idTokenCallCount2); + // The tokens may or may not be identical depending on timing, but the flow ran again. + Assert.NotNull(tokens2.AccessToken); + } +} diff --git a/tests/ModelContextProtocol.TestOAuthServer/JagTokenExchangeResponse.cs b/tests/ModelContextProtocol.TestOAuthServer/JagTokenExchangeResponse.cs new file mode 100644 index 0000000..cae8a94 --- /dev/null +++ b/tests/ModelContextProtocol.TestOAuthServer/JagTokenExchangeResponse.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.TestOAuthServer; + +/// +/// Represents the token exchange response for the Identity Assertion JWT Authorization Grant (ID-JAG) +/// per RFC 8693 / SEP-990. +/// +internal sealed class JagTokenExchangeResponse +{ + /// + /// Gets or sets the issued JWT Authorization Grant (JAG). + /// Despite the field name "access_token" (required by RFC 8693), this contains a JAG JWT, + /// not an OAuth access token. + /// + [JsonPropertyName("access_token")] + public required string AccessToken { get; init; } + + /// + /// Gets or sets the type of security token issued. + /// For SEP-990, this MUST be "urn:ietf:params:oauth:token-type:id-jag". + /// + [JsonPropertyName("issued_token_type")] + public required string IssuedTokenType { get; init; } + + /// + /// Gets or sets the token type. + /// For SEP-990, this MUST be "N_A" per RFC 8693 §2.2.1 because the JAG is not an access token. + /// + [JsonPropertyName("token_type")] + public required string TokenType { get; init; } + + /// + /// Gets or sets the lifetime in seconds of the issued JAG. + /// + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; init; } +} diff --git a/tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs b/tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs index 6caaaea..e8c9827 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs @@ -5,6 +5,7 @@ namespace ModelContextProtocol.TestOAuthServer; [JsonSerializable(typeof(OAuthServerMetadata))] [JsonSerializable(typeof(AuthorizationServerMetadata))] [JsonSerializable(typeof(TokenResponse))] +[JsonSerializable(typeof(JagTokenExchangeResponse))] [JsonSerializable(typeof(JsonWebKeySet))] [JsonSerializable(typeof(JsonWebKey))] [JsonSerializable(typeof(TokenIntrospectionResponse))] diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index e882ecb..1da81c2 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -57,6 +57,18 @@ public sealed class Program // Track if we've already issued an already-expired token for the CanAuthenticate_WithTokenRefresh test which uses the test-refresh-client registration. public bool HasRefreshedToken { get; set; } + /// + /// Gets or sets a value indicating whether the server supports the Enterprise Managed + /// Authorization (SEP-990) flow, including the IdP token-exchange endpoint and the + /// JWT-bearer grant type at the token endpoint. + /// + /// + /// When true, the server registers enterprise test clients and activates the + /// /idp/token endpoint (RFC 8693 token exchange) and the + /// urn:ietf:params:oauth:grant-type:jwt-bearer grant type (RFC 7523). + /// + public bool EnterpriseSupportEnabled { get; set; } + /// /// Gets or sets a value indicating whether the authorization server /// advertises support for client ID metadata documents in its discovery @@ -158,6 +170,25 @@ public sealed class Program RedirectUris = ["http://localhost:1179/callback"], }; + // Enterprise Auth (SEP-990) clients. + // The IdP client is used to authenticate calls to /idp/token (token exchange). + // The MCP client is used to authenticate calls to /token (jwt-bearer grant). + // Neither needs redirect URIs because neither uses the authorization code flow. + _clients["enterprise-idp-client"] = new ClientInfo + { + ClientId = "enterprise-idp-client", + ClientSecret = "enterprise-idp-secret", + RequiresClientSecret = true, + RedirectUris = [], + }; + _clients["enterprise-mcp-client"] = new ClientInfo + { + ClientId = "enterprise-mcp-client", + ClientSecret = "enterprise-mcp-secret", + RequiresClientSecret = true, + RedirectUris = [], + }; + // The MCP spec tells the client to use /.well-known/oauth-authorization-server but AddJwtBearer looks for // /.well-known/openid-configuration by default. // @@ -348,10 +379,18 @@ public sealed class Program type: "https://tools.ietf.org/html/rfc6749#section-5.2"); } + // Read grant type early so we can skip resource validation for grant types that + // don't use the resource parameter (e.g. jwt-bearer where the resource is embedded + // inside the JWT assertion itself). + var grant_type = form["grant_type"].ToString(); + // Validate resource in accordance with RFC 8707. // When ExpectResource is false, the resource parameter must be absent (legacy mode). + // RFC 7523 JWT-bearer assertions carry the target resource inside the JWT itself, + // so we skip the form-level resource check for that grant type. var resource = form["resource"].ToString(); - if (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)) + if (grant_type != "urn:ietf:params:oauth:grant-type:jwt-bearer" && + (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource))) { return Results.BadRequest(new OAuthErrorResponse { @@ -360,7 +399,6 @@ public sealed class Program }); } - var grant_type = form["grant_type"].ToString(); if (grant_type == "authorization_code") { var code = form["code"].ToString(); @@ -437,6 +475,45 @@ public sealed class Program HasRefreshedToken = true; return Results.Ok(response); } + else if (grant_type == "urn:ietf:params:oauth:grant-type:jwt-bearer") + { + if (!EnterpriseSupportEnabled) + { + return Results.BadRequest(new OAuthErrorResponse + { + Error = "unsupported_grant_type", + ErrorDescription = "JWT bearer grant is not enabled on this server." + }); + } + + var assertion = form["assertion"].ToString(); + if (string.IsNullOrEmpty(assertion)) + { + return Results.BadRequest(new OAuthErrorResponse + { + Error = "invalid_request", + ErrorDescription = "assertion is required for jwt-bearer grant" + }); + } + + // Extract the target resource from the JAG payload (set during /idp/token). + // Fall back to ValidResources[0] so the token is still usable in tests even + // if the resource claim is absent. + var jagResource = ExtractJwtClaim(assertion, "resource"); + if (string.IsNullOrEmpty(jagResource) || !ValidResources.Contains(jagResource)) + { + jagResource = ValidResources.Length > 0 ? ValidResources[0] : null; + } + + var resourceUri = jagResource is not null ? new Uri(jagResource) : null; + var scope = form["scope"].ToString(); + var scopes = string.IsNullOrEmpty(scope) + ? ["mcp:tools"] + : scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); + + var response = GenerateJwtTokenResponse(client.ClientId, scopes, resourceUri); + return Results.Ok(response); + } else { return Results.BadRequest(new OAuthErrorResponse @@ -447,6 +524,77 @@ public sealed class Program } }); + // IdP token-exchange endpoint (RFC 8693) for Enterprise Managed Authorization (SEP-990). + // Exchanges an enterprise ID token (from SSO) for a JWT Authorization Grant (JAG) + // that can subsequently be used at the /token endpoint via the jwt-bearer grant. + app.MapPost("/idp/token", async (HttpContext context) => + { + if (!EnterpriseSupportEnabled) + { + return Results.NotFound(); + } + + var form = await context.Request.ReadFormAsync(); + + // Authenticate the IdP client. + var client = AuthenticateClient(context, form); + if (client == null) + { + context.Response.StatusCode = 401; + return Results.Problem( + statusCode: 401, + title: "Unauthorized", + detail: "Invalid client credentials", + type: "https://tools.ietf.org/html/rfc6749#section-5.2"); + } + + var grantType = form["grant_type"].ToString(); + if (grantType != "urn:ietf:params:oauth:grant-type:token-exchange") + { + return Results.BadRequest(new OAuthErrorResponse + { + Error = "unsupported_grant_type", + ErrorDescription = "Only urn:ietf:params:oauth:grant-type:token-exchange is supported on this endpoint." + }); + } + + var subjectToken = form["subject_token"].ToString(); + if (string.IsNullOrEmpty(subjectToken)) + { + return Results.BadRequest(new OAuthErrorResponse + { + Error = "invalid_request", + ErrorDescription = "subject_token is required." + }); + } + + var requestedTokenType = form["requested_token_type"].ToString(); + if (requestedTokenType != "urn:ietf:params:oauth:token-type:id-jag") + { + return Results.BadRequest(new OAuthErrorResponse + { + Error = "invalid_request", + ErrorDescription = "requested_token_type must be urn:ietf:params:oauth:token-type:id-jag." + }); + } + + var audience = form["audience"].ToString(); + var resourceParam = form["resource"].ToString(); + + // Generate a JAG JWT signed with the server's RSA key. + // The JAG encodes the intended audience (MCP AS) and resource (MCP server) so + // the /token endpoint can later issue a correctly-scoped access token. + var jag = GenerateJagJwt(audience, resourceParam); + + return Results.Ok(new JagTokenExchangeResponse + { + AccessToken = jag, + IssuedTokenType = "urn:ietf:params:oauth:token-type:id-jag", + TokenType = "N_A", + ExpiresIn = 300, + }); + }); + // Introspection endpoint app.MapPost("/introspect", async (HttpContext context) => { @@ -675,6 +823,70 @@ public sealed class Program }; } + /// + /// Generates a JWT Authorization Grant (JAG) signed with the server's RSA key. + /// The JAG encodes the target audience (MCP AS URL) and the resource (MCP server URL). + /// + private string GenerateJagJwt(string audience, string resource) + { + var expiresIn = TimeSpan.FromMinutes(5); + var issuedAt = DateTimeOffset.UtcNow; + var expiresAt = issuedAt.Add(expiresIn); + + var header = new Dictionary + { + { "alg", "RS256" }, + { "typ", "JWT" }, + { "kid", _keyId }, + }; + + var payload = new Dictionary + { + { "iss", _url }, + { "sub", "enterprise-user" }, + { "aud", audience }, + { "resource", resource }, // carried through so /token can issue the right audience + { "jti", Guid.NewGuid().ToString() }, + { "iat", issuedAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) }, + { "exp", expiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) }, + }; + + var headerJson = System.Text.Json.JsonSerializer.Serialize(header, OAuthJsonContext.Default.DictionaryStringString); + var payloadJson = System.Text.Json.JsonSerializer.Serialize(payload, OAuthJsonContext.Default.DictionaryStringString); + + var headerBase64 = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); + var payloadBase64 = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); + + var dataToSign = $"{headerBase64}.{payloadBase64}"; + var signature = _rsa.SignData(Encoding.UTF8.GetBytes(dataToSign), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + return $"{headerBase64}.{payloadBase64}.{WebEncoders.Base64UrlEncode(signature)}"; + } + + /// + /// Decodes a JWT payload (without signature verification) and returns the value of + /// , or null if the claim is absent or the JWT is malformed. + /// + private static string? ExtractJwtClaim(string jwt, string claimName) + { + var parts = jwt.Split('.'); + if (parts.Length < 2) + { + return null; + } + + try + { + var payloadJson = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(parts[1])); + var payload = System.Text.Json.JsonSerializer.Deserialize(payloadJson, OAuthJsonContext.Default.DictionaryStringString); + return payload?.TryGetValue(claimName, out var value) == true ? value : null; + } + catch + { + return null; + } + } + /// /// Generates a random token for authorization code or refresh token. /// diff --git a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs new file mode 100644 index 0000000..44afcce --- /dev/null +++ b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs @@ -0,0 +1,389 @@ +using System.Net; +using System.Text.Json.Nodes; +using ModelContextProtocol.Authentication; + +namespace ModelContextProtocol.Tests; + +public sealed class IdentityAssertionGrantTests : IDisposable +{ + private readonly MockHttpMessageHandler _mockHandler; + private readonly HttpClient _httpClient; + + public IdentityAssertionGrantTests() + { + _mockHandler = new MockHttpMessageHandler(); + _httpClient = new HttpClient(_mockHandler); + } + + public void Dispose() + { + _httpClient.Dispose(); + _mockHandler.Dispose(); + } + + #region IdentityAssertionGrantProvider Tests + + [Fact] + public async Task IdentityAssertionGrantProvider_FullFlow_ReturnsAccessToken() + { + _mockHandler.Handler = request => + { + var url = request.RequestUri!.ToString(); + + if (url.Contains(".well-known/openid-configuration")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["issuer"] = "https://auth.mcp-server.example.com", + ["authorization_endpoint"] = "https://auth.mcp-server.example.com/authorize", + ["token_endpoint"] = "https://auth.mcp-server.example.com/token", + }); + } + + if (url.Contains("idp.example.com/token")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "mock-jag-assertion", + ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag", + ["token_type"] = "N_A", + }); + } + + if (url.Contains("auth.mcp-server.example.com/token")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "final-access-token", + ["token_type"] = "Bearer", + ["expires_in"] = 3600, + }); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "mcp-client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (context, ct) => + { + Assert.Equal(new Uri("https://mcp-server.example.com"), context.ResourceUrl); + Assert.Equal(new Uri("https://auth.mcp-server.example.com"), context.AuthorizationServerUrl); + return Task.FromResult("mock-id-token"); + }, + }, + _httpClient); + + var tokens = await provider.GetAccessTokenAsync( + resourceUrl: new Uri("https://mcp-server.example.com"), + authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"), + TestContext.Current.CancellationToken); + + Assert.Equal("final-access-token", tokens.AccessToken); + Assert.Equal("Bearer", tokens.TokenType); + Assert.Equal(3600, tokens.ExpiresIn); + } + + [Fact] + public async Task IdentityAssertionGrantProvider_CachesTokens() + { + var mcpTokenCallCount = 0; + _mockHandler.Handler = request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains(".well-known")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["authorization_endpoint"] = "https://auth.example.com/authorize", + ["token_endpoint"] = "https://auth.example.com/token", + }); + } + + if (url.Contains("idp.example.com")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "mock-jag", + ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag", + ["token_type"] = "N_A", + }); + } + + mcpTokenCallCount++; + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "cached-token", + ["token_type"] = "Bearer", + ["expires_in"] = 3600, + }); + }; + + var idTokenCallCount = 0; + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => + { + idTokenCallCount++; + return Task.FromResult("mock-id-token"); + }, + }, + _httpClient); + + var ct = TestContext.Current.CancellationToken; + + var firstTokens = await provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri("https://auth.example.com"), ct); + + var secondTokens = await provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri("https://auth.example.com"), ct); + + Assert.Same(firstTokens, secondTokens); + Assert.Equal(1, idTokenCallCount); + Assert.Equal(1, mcpTokenCallCount); + } + + [Fact] + public async Task IdentityAssertionGrantProvider_InvalidateCache_ForcesRefresh() + { + var idTokenCallCount = 0; + _mockHandler.Handler = request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains(".well-known")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["authorization_endpoint"] = "https://auth.example.com/authorize", + ["token_endpoint"] = "https://auth.example.com/token", + }); + } + + if (url.Contains("idp.example.com")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "mock-jag", + ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag", + ["token_type"] = "N_A", + }); + } + + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = $"token-{idTokenCallCount}", + ["token_type"] = "Bearer", + ["expires_in"] = 3600, + }); + }; + + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => + { + idTokenCallCount++; + return Task.FromResult("mock-id-token"); + }, + }, + _httpClient); + + var ct = TestContext.Current.CancellationToken; + + await provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri("https://auth.example.com"), ct); + + provider.InvalidateCache(); + + await provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri("https://auth.example.com"), ct); + + Assert.Equal(2, idTokenCallCount); + } + + [Fact] + public async Task IdentityAssertionGrantProvider_IdTokenCallbackReturnsEmpty_ThrowsException() + { + _mockHandler.Handler = request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains(".well-known")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["authorization_endpoint"] = "https://auth.example.com/authorize", + ["token_endpoint"] = "https://auth.example.com/token", + }); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult(string.Empty), + }, + _httpClient); + + await Assert.ThrowsAsync( + () => provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri("https://auth.example.com"), + TestContext.Current.CancellationToken)); + } + + [Fact] + public void IdentityAssertionGrantProvider_NullOptions_ThrowsArgumentNullException() + { + Assert.Throws(() => new IdentityAssertionGrantProvider(null!, _httpClient)); + } + + [Fact] + public void IdentityAssertionGrantProvider_NullHttpClient_ThrowsArgumentNullException() + { + Assert.Throws(() => new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult("token"), + }, + null!)); + } + + [Fact] + public void IdentityAssertionGrantProvider_MissingClientId_ThrowsArgumentException() + { + Assert.Throws(() => new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult("test"), + }, + _httpClient)); + } + + [Fact] + public void IdentityAssertionGrantProvider_MissingIdTokenCallback_ThrowsArgumentNullException() + { + Assert.Throws(() => new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = null!, + }, + _httpClient)); + } + + [Fact] + public void IdentityAssertionGrantProvider_MissingIdpConfig_ThrowsArgumentException() + { + Assert.Throws(() => new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpClientId = "idp-client-id", + // Neither IdpUrl nor IdpTokenEndpoint provided + IdTokenCallback = (_, _) => Task.FromResult("test"), + }, + _httpClient)); + } + + #endregion + + #region IdentityAssertionGrantException Tests + + [Fact] + public void IdentityAssertionGrantException_WithErrorCodeAndDescription_FormatsMessage() + { + var ex = new IdentityAssertionGrantException("Base message", "invalid_grant", "Token expired"); + + Assert.Contains("Base message", ex.Message); + Assert.Contains("invalid_grant", ex.Message); + Assert.Contains("Token expired", ex.Message); + Assert.Equal("invalid_grant", ex.ErrorCode); + Assert.Equal("Token expired", ex.ErrorDescription); + } + + [Fact] + public void IdentityAssertionGrantException_WithErrorUri_StoresIt() + { + var ex = new IdentityAssertionGrantException("msg", "error", "desc", "https://docs.example.com/error"); + + Assert.Equal("https://docs.example.com/error", ex.ErrorUri); + } + + [Fact] + public void IdentityAssertionGrantException_WithoutErrorDetails_PlainMessage() + { + var ex = new IdentityAssertionGrantException("Simple error"); + + Assert.Equal("Simple error", ex.Message); + Assert.Null(ex.ErrorCode); + Assert.Null(ex.ErrorDescription); + Assert.Null(ex.ErrorUri); + } + + #endregion + + #region Helpers + + private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, JsonObject payload) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(payload.ToJsonString(), System.Text.Encoding.UTF8, "application/json") + }; + } + + private sealed class MockHttpMessageHandler : HttpMessageHandler + { + public Func? Handler { get; set; } + public Func>? AsyncHandler { get; set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (AsyncHandler is not null) + { + return await AsyncHandler(request); + } + + if (Handler is not null) + { + return Handler(request); + } + + return new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("No mock response configured") + }; + } + } + + #endregion +} diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index 1a999fd..8b78762 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -1,4 +1,5 @@ -using ModelContextProtocol.Client; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Tests.Utils; using System.IO.Pipelines; @@ -12,6 +13,42 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log { public static bool IsStdErrCallbackSupported => !PlatformDetection.IsMonoRuntime; + [Fact] + public async Task ConnectAsync_DoesNotLogEnvironmentVariablesAtTrace() + { + string secretName = $"MCP_TEST_SECRET_{Guid.NewGuid():N}"; + string secretValue = $"secret-{Guid.NewGuid():N}"; + + using var loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder => + { + builder.AddProvider(MockLoggerProvider); + builder.SetMinimumLevel(LogLevel.Trace); + }); + + StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + new(new() + { + Command = "cmd.exe", + Arguments = ["/c", "exit /b 0"], + EnvironmentVariables = new Dictionary { [secretName] = secretValue }, + }, loggerFactory) : + new(new() + { + Command = "sh", + Arguments = ["-c", "exit 0"], + EnvironmentVariables = new Dictionary { [secretName] = secretValue }, + }, loggerFactory); + + await using var _ = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + Assert.Contains(MockLoggerProvider.LogMessages, log => + log.LogLevel == LogLevel.Trace && + log.Message.Contains("starting server process", StringComparison.Ordinal)); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, log => + log.Message.Contains(secretName, StringComparison.Ordinal) || + log.Message.Contains(secretValue, StringComparison.Ordinal)); + } + [Fact] public async Task CreateAsync_ValidProcessInvalidServer_Throws() { @@ -150,6 +187,188 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log Assert.Equal(cliArgumentValue ?? "", content.Text); } + [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] + public async Task InheritEnvironmentVariables_DefaultTrue_ChildSeesParentEnvVars() + { + // Check the same variable the False test checks for absence (HOME on Unix, USERNAME on Windows) + // so the two tests form a direct symmetric pair: one asserts it IS set, the other asserts it is NOT. + var tcs = new TaskCompletionSource(); + StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + new(new() { Command = "cmd", Arguments = ["/c", "if defined USERNAME (echo USERNAME_IS_SET >&2) else (echo USERNAME_NOT_SET >&2) & exit /b 1"], StandardErrorLines = line => tcs.TrySetResult(line) }, LoggerFactory) : + new(new() { Command = "sh", Arguments = ["-c", "if [ -n \"$HOME\" ]; then echo HOME_IS_SET >&2; else echo HOME_NOT_SET >&2; fi; exit 1"], StandardErrorLines = line => tcs.TrySetResult(line) }, LoggerFactory); + + await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); + string capturedLine = await tcs.Task.WaitAsync(cts.Token); + + Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "USERNAME_IS_SET" : "HOME_IS_SET", capturedLine.Trim()); + } + + [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] + public async Task InheritEnvironmentVariables_False_ChildDoesNotSeeParentEnvVars() + { + // Pass PATH so cmd/sh can be located. Verify that HOME (Unix) / USERNAME (Windows), + // which are always set in the parent, are absent because they were not explicitly provided. + var tcs = new TaskCompletionSource(); + StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + new(new() + { + Command = "cmd", + Arguments = ["/c", "if defined USERNAME (echo USERNAME_IS_SET >&2) else (echo USERNAME_NOT_SET >&2) & exit /b 1"], + InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH") }, + StandardErrorLines = line => tcs.TrySetResult(line) + }, LoggerFactory) : + new(new() + { + Command = "sh", + Arguments = ["-c", "if [ -n \"$HOME\" ]; then echo HOME_IS_SET >&2; else echo HOME_NOT_SET >&2; fi; exit 1"], + InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH") }, + StandardErrorLines = line => tcs.TrySetResult(line) + }, LoggerFactory); + + await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); + string capturedLine = await tcs.Task.WaitAsync(cts.Token); + + // HOME / USERNAME were in the parent but not passed — should be absent in the child. + Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "USERNAME_NOT_SET" : "HOME_NOT_SET", capturedLine.Trim()); + } + + [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] + public async Task InheritEnvironmentVariables_False_WithExplicitVars_ChildSeesOnlyExplicitVars() + { + // Pass PATH + one explicit var. Verify HOME (Unix) / USERNAME (Windows) is absent, + // and the explicitly provided variable is visible. + const string explicitVarName = "MCP_STDIO_TEST_EXPLICIT_VAR"; + const string explicitVarValue = "explicit_test_value"; + + var capturedLines = new List(); + var lineCount = 0; + var tcs = new TaskCompletionSource(); + void CaptureLines(string line) + { + lock (capturedLines) + { + capturedLines.Add(line.Trim()); + if (Interlocked.Increment(ref lineCount) >= 2) + tcs.TrySetResult(true); + } + } + + StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + new(new() + { + Command = "cmd", + Arguments = ["/c", + $"if defined USERNAME (echo USERNAME_IS_SET >&2) else (echo USERNAME_NOT_SET >&2) " + + $"& if defined {explicitVarName} (echo EXPLICIT_IS_SET >&2) else (echo EXPLICIT_NOT_SET >&2) " + + $"& exit /b 1"], + InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH"), [explicitVarName] = explicitVarValue }, + StandardErrorLines = CaptureLines + }, LoggerFactory) : + new(new() + { + Command = "sh", + Arguments = ["-c", + $"if [ -n \"$HOME\" ]; then echo HOME_IS_SET >&2; else echo HOME_NOT_SET >&2; fi; " + + $"if [ -n \"${explicitVarName}\" ]; then echo EXPLICIT_IS_SET >&2; else echo EXPLICIT_NOT_SET >&2; fi; exit 1"], + InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH"), [explicitVarName] = explicitVarValue }, + StandardErrorLines = CaptureLines + }, LoggerFactory); + + await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); + await tcs.Task.WaitAsync(cts.Token); + + string allOutput = string.Join(Environment.NewLine, capturedLines); + Assert.Contains(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "USERNAME_NOT_SET" : "HOME_NOT_SET", allOutput); + Assert.Contains("EXPLICIT_IS_SET", allOutput); + } + + [Fact] + public void GetDefaultEnvironmentVariables_ReturnsFreshDictionaryEachCall() + { + var first = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); + var second = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); + Assert.NotSame(first, second); + } + + [Fact] + public void GetDefaultEnvironmentVariables_ReturnsCorrectComparer() + { + var result = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Equal(StringComparer.OrdinalIgnoreCase, result.Comparer); + } + else + { + Assert.Equal(StringComparer.Ordinal, result.Comparer); + } + } + + [Fact] + public void GetDefaultEnvironmentVariables_ContainsOnlyAllowlistedKeys() + { + HashSet allowedKeys = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new(StringComparer.OrdinalIgnoreCase) + { + "APPDATA", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "PATH", "PATHEXT", + "PROCESSOR_ARCHITECTURE", "PROGRAMFILES", "SYSTEMDRIVE", "SYSTEMROOT", + "TEMP", "USERNAME", "USERPROFILE", + } + : new(StringComparer.Ordinal) + { + "HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER", + }; + + var result = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); + foreach (var key in result.Keys) + { + Assert.Contains(key, allowedKeys); + } + } + + [Fact] + public void GetDefaultEnvironmentVariables_ExcludesShellFunctionValues() + { + // Verify the postcondition: no returned values start with "()" (shell function markers). + var result = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); + foreach (var kvp in result) + { + Assert.False(kvp.Value?.StartsWith("()") ?? false, + $"Value for '{kvp.Key}' starts with '()' and should have been filtered as a shell function."); + } + } + + [Fact] + public void GetDefaultEnvironmentVariables_PathIsPresent_WhenSetInEnvironment() + { + // PATH is always set in a real process environment; verify it is included. + var result = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); + if (Environment.GetEnvironmentVariable("PATH") is not null) + { + Assert.True(result.ContainsKey("PATH"), "PATH should be present when it exists in the parent environment."); + } + } + + [Fact] + public void GetDefaultEnvironmentVariables_DoesNotIncludeNonAllowlistedKeys() + { + // Keys that are definitely not on the allowlist must never appear. + var result = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); + Assert.False(result.ContainsKey("AWS_SECRET_ACCESS_KEY")); + Assert.False(result.ContainsKey("GITHUB_TOKEN")); + Assert.False(result.ContainsKey("OPENAI_API_KEY")); + } + [Fact] public async Task SendMessageAsync_Should_Use_LF_Not_CRLF() {