diff --git a/CHANGELOG.md b/CHANGELOG.md index b769e81..a66ca57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,14 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **Bounded install-source attribution.** The `start` command accepts optional + `--install-source`, `--install-content`, and `--install-campaign` arguments (plus + environment-variable equivalents) and appends their validated, non-personal labels to + the existing Graph/ARM request `User-Agent`. After MCP initialization, the server also + maps the self-reported client name to a bounded agent-host label (for example `vscode` + or `claude-code`); unknown raw client names are never transmitted. + `--no-install-attribution` provides an explicit opt-out for all attribution labels. The + README install links declare `github-readme` as their source. - **Per-instance data directory.** New `--data-dir ` flag and `SPE_DATA_DIR` environment variable select where the provisioning `state.json` and MSAL token cache are stored (precedence: flag > env > default `~/.spe-mcp`). Point each diff --git a/NOTICE.md b/NOTICE.md index f491e89..f36e040 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -18,22 +18,23 @@ help documentation and our privacy statement. Your use of the software operates consent to these practices. > **What this build sends.** `@microsoft/spe-mcp` opens **no separate telemetry channel** and -> sends **no usage analytics or personal, tenant, or per-user data** to Microsoft. The only -> Microsoft-bound signal is a static product `User-Agent` token -> (`spe-mcp-server/`) attached to the Microsoft Graph and Azure Resource Manager -> requests you already make on your own behalf; it carries no personal, tenant, or usage -> data and is used only for aggregate traffic attribution. It is **on by default** and can be +> sends **no usage analytics or personal, tenant, or per-user data** to Microsoft. Its only +> Microsoft-bound signals are a product `User-Agent` token (`spe-mcp-server/`) and +> optional bounded install-source, content, campaign, and self-reported agent-host tokens +> attached to the Microsoft Graph and Azure Resource Manager requests you already make on your +> own behalf. They carry no personal, tenant, or usage data and are used only for aggregate +> traffic attribution. Attribution is **on by default** and can be > suppressed with `SPE_MCP_COLLECT_TELEMETRY=false` (see below). See [PRIVACY.md](PRIVACY.md) and > [docs/DATA-FLOW.md](docs/DATA-FLOW.md) for the full data-flow description. ## Telemetry configuration Telemetry collection is controlled by the `SPE_MCP_COLLECT_TELEMETRY` environment variable and is -**on by default**. The only telemetry this build emits is the static product `User-Agent` -token (`spe-mcp-server/`) stamped on outbound Graph/ARM requests for aggregate traffic -attribution — there is no usage-analytics channel and no personal, tenant, or per-user data. To -opt out, set `SPE_MCP_COLLECT_TELEMETRY=false` in your environment; the product token is then -omitted from all outbound requests. +**on by default**. The only telemetry this build emits is the product and optional bounded +attribution tokens in the `User-Agent` stamped on outbound Graph/ARM requests for aggregate +traffic attribution — there is no usage-analytics channel and no personal, tenant, or per-user +data. To opt out, set `SPE_MCP_COLLECT_TELEMETRY=false` in your environment; all attribution +tokens are then omitted from outbound requests. ## Compliance responsibility diff --git a/PRIVACY.md b/PRIVACY.md index 7ef19b5..4288d4a 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -10,8 +10,8 @@ organization's agreements with Microsoft. ## What the tool collects and sends **The tool opens no dedicated usage-analytics channel and sends no personal, tenant, or -per-user data to Microsoft.** The only Microsoft-bound attribution signal is a static product -`User-Agent` token, which is on by default and can be turned off (see +per-user data to Microsoft.** Its Microsoft-bound attribution signals are bounded `User-Agent` +tokens, which are on by default and can be turned off (see [Turning it off](#turning-it-off)). Specifically: - **No telemetry channel.** The tool does not implement application telemetry and does not @@ -26,12 +26,16 @@ per-user data to Microsoft.** The only Microsoft-bound attribution signal is a s endpoints — Microsoft Graph and Azure Resource Manager — **on your behalf**, in **your** tenant and subscription. The content and directory data involved flow between your machine and those Microsoft services; the tool adds no additional recipients. -- **Product `User-Agent`.** Outbound Graph/ARM requests are stamped with a static - `User-Agent` of the form `spe-mcp-server/` (`src/user-agent.ts`). It contains - **no personal, tenant, or usage information** and exists only so the service can measure - aggregate traffic driven by this tool. It is a request header on calls you already make — - not a separate data feed — and it is **on by default**; set `SPE_MCP_COLLECT_TELEMETRY=false` - to omit it (see [Turning it off](#turning-it-off)). +- **Product and install-source `User-Agent`.** Outbound Graph/ARM requests are stamped + with `spe-mcp-server/` (`src/user-agent.ts`). Install links can also configure + bounded source, content, and campaign labels such as `microsoft-learn` and an article + slug. The MCP handshake's self-reported client name is mapped to a bounded agent-host + label; the raw name and client version are not transmitted in the request metadata. + These labels contain **no personal or tenant identifiers**, but they accompany each + authenticated request and Microsoft services can associate them with that request in + normal service logs. They exist so the service can measure aggregate traffic driven by + published install surfaces and agent hosts; they are not a separate data feed. Attribution + is **on by default**; set `SPE_MCP_COLLECT_TELEMETRY=false` to omit all of these tokens. See [docs/DATA-FLOW.md](docs/DATA-FLOW.md) for the full list of network endpoints and what travels to each. @@ -40,7 +44,7 @@ travels to each. > software "may collect information about you and your use of the software and send it to > Microsoft" (full text in [NOTICE.md](NOTICE.md#data-collection)). It is reproduced for > completeness; **this build opens no usage-analytics channel** — the only Microsoft-bound -> signal is the product `User-Agent` attribution token described above, which is on by default +> signals are the bounded `User-Agent` attribution tokens described above, which are on by default > and can be turned off (see [Turning it off](#turning-it-off) and the > [Telemetry configuration](NOTICE.md#telemetry-configuration) note). @@ -62,10 +66,14 @@ terms, which are outside the control of this project. ## Turning it off -The product `User-Agent` attribution token (`spe-mcp-server/`) is the only -Microsoft-bound telemetry signal, and it is **on by default**. To opt out, set -`SPE_MCP_COLLECT_TELEMETRY=false` in your environment; the tool then omits the token from all -outbound Graph and Azure Resource Manager requests. Those requests still go out — they simply +Because the tool has no telemetry channel, there is no separate telemetry stream to opt out +of. To omit install-source labels while retaining the product token, remove the +`--install-source`, `--install-content`, and `--install-campaign` arguments from the MCP +client configuration. To omit both install-source and agent-host labels, add +`--no-install-attribution`. All attribution is **on by default**. To opt out, set +`SPE_MCP_COLLECT_TELEMETRY=false` in your environment; the tool then omits the product, +install-source, content, campaign, and agent-host tokens from all outbound Graph and Azure +Resource Manager requests. Those requests still go out — they simply carry the underlying tool's default `User-Agent` instead (e.g. the Azure CLI's own token for `az`/`azd`, or the Node runtime default for direct Graph calls), whose logging is governed by those services' own terms. To further limit diff --git a/README.md b/README.md index c584ad6..1367fe5 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,24 @@ The server exposes **40 tools**, plus an MCP **Prompt** (`provision_spe_app`) an Run the published npm package directly from your MCP client with `npx`; no global install is required. +### One-click install + +[Install in Visual Studio Code](https://aka.ms/spe-mcp/install/github/vscode) + +One-click install is also available for [Visual Studio Code Insiders](https://aka.ms/spe-mcp/install/github/vscode-insiders), [Visual Studio](https://aka.ms/spe-mcp/install/github/visual-studio), and [Cursor](https://aka.ms/spe-mcp/install/github/cursor). From the command line, run `claude mcp add spe -- npx -y @microsoft/spe-mcp start --install-source github-readme --install-content readme-install --install-campaign docs-install-buttons` for Claude Code or `codex mcp add spe -- npx -y @microsoft/spe-mcp start --install-source github-readme --install-content readme-install --install-campaign docs-install-buttons` for the Codex CLI. + +These configurations add bounded, non-personal install-source labels to the +existing Graph and Azure request `User-Agent`; they create no separate telemetry +channel. Remove the three install-attribution arguments, or add +`--no-install-attribution`, to omit the labels. + +After the MCP handshake, the server also maps the client's self-reported +`clientInfo.name` to a bounded agent-host value such as `vscode`, `cursor`, or +`claude-code`. Unrecognized names become `other`; missing or generic SDK values +become `unknown`. The raw client name and client version are not transmitted, and +the classification is used only for attribution—not for authorization or any +security decision. + ### VS Code / Cursor Add an MCP server entry to `.vscode/mcp.json` (VS Code) or your Cursor MCP @@ -88,7 +106,17 @@ configuration: "spe": { "type": "stdio", "command": "npx", - "args": ["-y", "@microsoft/spe-mcp"] + "args": [ + "-y", + "@microsoft/spe-mcp", + "start", + "--install-source", + "github-readme", + "--install-content", + "readme-install", + "--install-campaign", + "docs-install-buttons" + ] } } } @@ -106,7 +134,17 @@ Add to `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or "mcpServers": { "spe": { "command": "npx", - "args": ["-y", "@microsoft/spe-mcp"] + "args": [ + "-y", + "@microsoft/spe-mcp", + "start", + "--install-source", + "github-readme", + "--install-content", + "readme-install", + "--install-campaign", + "docs-install-buttons" + ] } } } @@ -197,8 +235,12 @@ The server accepts configuration via CLI flags or environment variables: | `--tenant-id` | `SPE_TENANT_ID` | Entra ID Tenant ID | | `--read-only` | `SPE_READ_ONLY` | Advertise/allow only read/list/get/search tools; reject mutating calls | | `--tools` | `SPE_TOOLS` | Restrict exposed tools to a profile (`readOnly`, `docsOnly`, `provisioning`, `content`, `admin`) or a comma-separated tool list | +| `--install-source` | `SPE_INSTALL_SOURCE` | Optional bounded install surface: `microsoft-learn`, `github-readme`, `github-release`, `mcp-registry`, `npm`, or `other` | +| `--install-content` | `SPE_INSTALL_CONTENT` | Optional bounded content identifier: `readme-install`, `sharepoint-embedded-mcp-server`, `quickstart-vscode`, `create-container-type`, or `create-manage-containers`; requires an install source | +| `--install-campaign` | `SPE_INSTALL_CAMPAIGN` | Optional bounded campaign identifier: `docs-install-buttons`; requires an install source | +| `--no-install-attribution` | `SPE_INSTALL_ATTRIBUTION=off` | Omit install-source and agent-host labels from outbound request metadata | | `--data-dir` | `SPE_DATA_DIR` | Directory for the token cache + provisioning state (default `~/.spe-mcp`). Point each instance at a unique **absolute** path (or `~/...`; CWD-relative paths are rejected) to run multiple servers without clobbering state | -| _(none)_ | `SPE_MCP_COLLECT_TELEMETRY` | Product `User-Agent` attribution token on outbound Graph/ARM requests. On by default; set to `false` to opt out (see [PRIVACY.md](PRIVACY.md)) | +| _(none)_ | `SPE_MCP_COLLECT_TELEMETRY` | Product and optional bounded `User-Agent` attribution tokens on outbound Graph/ARM requests. On by default; set to `false` to opt out (see [PRIVACY.md](PRIVACY.md)) | > The CLI flag wins when both a flag and its env var are set. Run > `spe-mcp start --help` to see the authoritative option list and descriptions. @@ -278,7 +320,7 @@ Add to `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or `~/Library/App ```bash # Start the MCP server (stdio transport) -spe-mcp start [--client-id ID] [--tenant-id ID] [--read-only] [--tools ] +spe-mcp start [--client-id ID] [--tenant-id ID] [--read-only] [--tools ] [--install-source ] # Authenticate interactively (cache tokens for headless use) spe-mcp auth --client-id ID --tenant-id ID [--reset] @@ -296,6 +338,10 @@ Every command has built-in help — run `spe-mcp --help` (e.g. | `--tenant-id ` | Entra ID Tenant ID. Discovered from the Azure CLI when omitted. | | `--read-only` | Read-only mode: only read/list/get/search tools are exposed and callable. | | `--tools ` | Tool allowlist: a profile (`readOnly`, `docsOnly`, `provisioning`, `content`, `admin`) or a comma-separated list of tool names. | +| `--install-source ` | Add a bounded install surface to the existing Graph/ARM request `User-Agent`. | +| `--install-content ` | Add one of the bounded content identifiers listed in [Configuration](#configuration); requires `--install-source`. | +| `--install-campaign ` | Add the bounded `docs-install-buttons` campaign identifier; requires `--install-source`. | +| `--no-install-attribution` | Omit install-source and agent-host labels from outbound request metadata. | ## Authentication @@ -342,12 +388,32 @@ The data directory holds a single provisioning `state.json` plus the token cache "servers": { "spe-tenantA": { "command": "npx", - "args": ["-y", "@microsoft/spe-mcp", "start"], + "args": [ + "-y", + "@microsoft/spe-mcp", + "start", + "--install-source", + "github-readme", + "--install-content", + "readme-install", + "--install-campaign", + "docs-install-buttons" + ], "env": { "SPE_DATA_DIR": "~/.spe-mcp-tenantA", "SPE_TENANT_ID": "" } }, "spe-tenantB": { "command": "npx", - "args": ["-y", "@microsoft/spe-mcp", "start"], + "args": [ + "-y", + "@microsoft/spe-mcp", + "start", + "--install-source", + "github-readme", + "--install-content", + "readme-install", + "--install-campaign", + "docs-install-buttons" + ], "env": { "SPE_DATA_DIR": "~/.spe-mcp-tenantB", "SPE_TENANT_ID": "" } } } @@ -388,7 +454,7 @@ src/ ├── resources.ts — MCP Resources (reference architectures) ├── reference-architectures.ts — Reference-architecture catalog (reads ../samples/) ├── elicitation.ts — Interactive consent / step-up prompts -├── user-agent.ts — Product User-Agent token + SPE_MCP_COLLECT_TELEMETRY opt-out +├── user-agent.ts — Product/bounded User-Agent attribution + telemetry opt-out ├── types.ts — Shared TypeScript types └── tools/ — 31 tools across 28 modules (one McpTool per export) ├── status.ts — status_get @@ -592,29 +658,33 @@ Resource Manager — **on your behalf**; the content and directory data involved between your machine, your MCP client, and those Microsoft services in your own tenant/subscription. -The server opens **no separate telemetry channel** and sends **no usage analytics** to -Microsoft. Outbound Graph/ARM requests carry a **static product `User-Agent`** -(`spe-mcp-server/`) that contains **no personal, tenant, or usage data** and is -used only for aggregate traffic attribution. That attribution token is **on by default** and -can be suppressed with `SPE_MCP_COLLECT_TELEMETRY=false` (see **Telemetry configuration** below). -Authentication tokens are cached locally with owner-only permissions (**SEC-003**). For -details see [PRIVACY.md](PRIVACY.md) and [docs/DATA-FLOW.md](docs/DATA-FLOW.md); Microsoft's -handling of data you send to its online services is described in the +The server opens **no separate telemetry channel**. Each authenticated Graph/ARM request +carries a product `User-Agent` (`spe-mcp-server/`). An install configuration can +add bounded source, content, campaign, and self-reported agent-host labels to that +request header. The raw MCP client name and version are not sent in these labels. +The labels contain no personal or tenant identifiers, but Microsoft services can +associate them with the authenticated request in normal service logs. Omit install and +agent-host labels with `--no-install-attribution`, or all attribution tokens with +`SPE_MCP_COLLECT_TELEMETRY=false`. Authentication tokens are cached locally with owner-only +permissions (**SEC-003**). For details see [PRIVACY.md](PRIVACY.md) and +[docs/DATA-FLOW.md](docs/DATA-FLOW.md); Microsoft's handling of data you send to its online +services is described in the [Microsoft Privacy Statement](https://privacy.microsoft.com/privacystatement). **Data collection (standard Microsoft notice).** The software may collect information about you and your use of the software and send it to Microsoft; Microsoft may use this information to provide and improve products and services, and your use of the software operates as your consent to these practices (full text in [NOTICE.md](NOTICE.md#data-collection)). **This -build opens no usage-analytics channel** — the only Microsoft-bound signal is the product -`User-Agent` attribution token described above, which you can turn off with +build opens no usage-analytics channel** — its only Microsoft-bound signals are the bounded +`User-Agent` attribution tokens described above, which you can turn off with `SPE_MCP_COLLECT_TELEMETRY=false`. **Telemetry configuration.** Attribution is gated by the `SPE_MCP_COLLECT_TELEMETRY` environment -variable and is **on by default**. The only telemetry emitted is the static product -`User-Agent` token on outbound Graph/ARM requests (aggregate traffic attribution — no usage -analytics, no personal/tenant/per-user data). Set `SPE_MCP_COLLECT_TELEMETRY=false` to omit the -token from all outbound requests; those requests then fall back to the underlying tool's +variable and is **on by default**. The only telemetry emitted is the product and optional +bounded attribution tokens in the `User-Agent` on outbound Graph/ARM requests (aggregate +traffic attribution — no usage analytics or personal/tenant/per-user data). Set +`SPE_MCP_COLLECT_TELEMETRY=false` to omit all attribution tokens from outbound requests; +those requests then fall back to the underlying tool's default `User-Agent` (the Azure CLI's own token for `az`/`azd`; the Node runtime default for direct Graph calls). diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md index ee36255..1df7f83 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -48,11 +48,16 @@ These never leave your machine: ## Telemetry -The server opens **no usage-analytics channel** and does **not** collect telemetry — it only -adds a product `User-Agent` for aggregate product-usage attribution. That signal is a static -product `User-Agent` (`spe-mcp-server/`) with no personal, tenant, or usage data, -stamped on outbound Graph/ARM requests. It is **on by default**; set -`SPE_MCP_COLLECT_TELEMETRY=false` to omit it. Opting out neither silences the request nor adds a +The server opens **no separate telemetry channel**. Each authenticated Graph/ARM request +carries a product `User-Agent` (`spe-mcp-server/`). Install configurations can +add bounded source, content, and campaign labels to that request header. The labels contain +no personal or tenant identifiers. The MCP handshake's self-reported client name is also +mapped to a bounded agent-host label; the raw name and client version are not transmitted +in the request metadata. Microsoft services can associate these labels with the authenticated +request in normal service logs. Users can omit install and agent-host labels with +`--no-install-attribution`. All attribution is **on by default**; set +`SPE_MCP_COLLECT_TELEMETRY=false` to omit every product and bounded attribution token. +Opting out neither silences the request nor adds a new signal — outbound calls simply fall back to the underlying tool's default `User-Agent` (the Azure CLI's own token for `az`/`azd`; the Node runtime default for direct Graph calls), whose logging is governed by those services' own terms. See [PRIVACY.md](../PRIVACY.md) for diff --git a/package-lock.json b/package-lock.json index 49c6907..2e84c8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2087,9 +2087,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", diff --git a/src/cli.ts b/src/cli.ts index 0e3e73d..38ce82a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,12 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Command } from "commander"; +import { + INSTALL_CAMPAIGNS, + INSTALL_CONTENTS, + INSTALL_SOURCES, + resolveInstallAttribution, +} from "./user-agent.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -75,8 +81,34 @@ program "--tools ", "Restrict exposed tools: a built-in profile (readOnly, docsOnly, provisioning, content, admin) or a comma-separated list of tool names. Can also be set via SPE_TOOLS.", ) + .option( + "--install-source ", + `Bounded install surface added to existing Graph/ARM requests (${INSTALL_SOURCES.join(", ")}). Can also be set via SPE_INSTALL_SOURCE.`, + ) + .option( + "--install-content ", + `Optional bounded content identifier (${INSTALL_CONTENTS.join(", ")}). Requires --install-source. Can also be set via SPE_INSTALL_CONTENT.`, + ) + .option( + "--install-campaign ", + `Optional bounded campaign identifier (${INSTALL_CAMPAIGNS.join(", ")}). Requires --install-source. Can also be set via SPE_INSTALL_CAMPAIGN.`, + ) + .option( + "--no-install-attribution", + "Do not add install-source or self-reported agent-host metadata to outbound requests.", + ) .option("--data-dir ", DATA_DIR_OPTION) - .action(async (options: { clientId?: string; tenantId?: string; readOnly?: boolean; tools?: string; dataDir?: string }) => { + .action(async (options: { + clientId?: string; + tenantId?: string; + readOnly?: boolean; + tools?: string; + installSource?: string; + installContent?: string; + installCampaign?: string; + installAttribution?: boolean; + dataDir?: string; + }) => { try { // Resolve + record the data dir FIRST, before importing ./index.js (which // pulls in state.ts/auth.ts) so every entry point resolves the same dir. @@ -87,12 +119,30 @@ program const readOnly = options.readOnly === true || isTruthyEnv(process.env.SPE_READ_ONLY); // Tool allowlist/profile: CLI flag wins; otherwise SPE_TOOLS env. const tools = options.tools || process.env.SPE_TOOLS; + const attributionEnabled = + options.installAttribution !== false && + !["0", "false", "no", "off"].includes( + (process.env.SPE_INSTALL_ATTRIBUTION ?? "").trim().toLowerCase(), + ); + const installAttribution = resolveInstallAttribution({ + source: options.installSource || process.env.SPE_INSTALL_SOURCE, + content: options.installContent || process.env.SPE_INSTALL_CONTENT, + campaign: options.installCampaign || process.env.SPE_INSTALL_CAMPAIGN, + enabled: attributionEnabled, + }); // Both are optional. With no client-id the server runs in bootstrap mode: // the Azure CLI provides the control-plane token and the owning app is // provisioned on demand. const { startServer } = await import("./index.js"); - await startServer({ clientId, tenantId, readOnly, tools }); + await startServer({ + clientId, + tenantId, + readOnly, + tools, + installAttribution, + attributionEnabled, + }); } catch (error) { console.error("Failed to start SPE MCP server:"); if (error instanceof Error) { diff --git a/src/graph-client.test.ts b/src/graph-client.test.ts index 006a50a..8f70daf 100644 --- a/src/graph-client.test.ts +++ b/src/graph-client.test.ts @@ -25,6 +25,12 @@ import { desiredGraphResourceAccess, LOCAL_SPA_REDIRECT_URI, } from "./graph-client.js"; +import { + __testing as userAgentTesting, + resolveInstallAttribution, + setAgentHostAttribution, + setInstallAttribution, +} from "./user-agent.js"; // updateContainerType uses the default getAccessToken (MSAL); mock it so the // container-type update tests run fully offline. The other tests here pass an @@ -112,13 +118,42 @@ beforeEach(() => { for (const key of Object.keys(stateStore)) delete stateStore[key]; readStateMock.mockClear(); writeStateMock.mockClear(); + userAgentTesting.reset(); }); afterEach(() => { + userAgentTesting.reset(); vi.restoreAllMocks(); // restores the console.error spy globalThis.fetch = realFetch; // restore the directly-mutated global fetch }); +describe("request attribution", () => { + it("adds configured install attribution to Graph requests", async () => { + fetchMock.mockResolvedValueOnce( + okResponse({ + id: "object-id", + appId: "client-id", + displayName: "Test App", + }), + ); + setInstallAttribution( + resolveInstallAttribution({ + source: "microsoft-learn", + content: "sharepoint-embedded-mcp-server", + campaign: "docs-install-buttons", + }), + ); + setAgentHostAttribution("vscode"); + + await createApplication("Test App", getToken); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect((init.headers as Record)["User-Agent"]).toMatch( + /spe-mcp-server\/\S+ spe-install-source\/microsoft-learn spe-install-content\/sharepoint-embedded-mcp-server spe-install-campaign\/docs-install-buttons spe-agent-host\/vscode/, + ); + }); +}); + /** Parse the requiredResourceAccess PATCH body from the Nth fetch call. */ function patchedRequiredResourceAccess(callIndex: number): RequiredResourceAccess[] { const call = fetchMock.mock.calls[callIndex]; @@ -751,4 +786,3 @@ describe("getSignedInUser — /me select includes userType (guest handling, PR # expect(me.id).toBe("user-1"); }); }); - diff --git a/src/graph-client.ts b/src/graph-client.ts index 55b94a4..0bb5505 100644 --- a/src/graph-client.ts +++ b/src/graph-client.ts @@ -151,10 +151,8 @@ async function graphRequest( Authorization: `Bearer ${token}`, "Content-Type": "application/json", }; - // Merge caller headers first, then apply the product User-Agent attribution - // policy: stamp the token when telemetry is on (a caller-supplied User-Agent - // still wins) and strip any User-Agent when opted out, so the documented - // opt-out can't be bypassed by current or future call sites. + // Merge caller headers first, then replace this tool's attribution tokens + // while preserving unrelated caller User-Agent content. const headers = applyProductUserAgent({ ...baseHeaders, ...customHeaders }); const options: RequestInit = { method, headers }; diff --git a/src/index.ts b/src/index.ts index 4e3a013..7951e79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,12 @@ import { initializeAuth, setAuthConfig } from "./auth.js"; import { assertAzCli, getSignedInIdentity } from "./bootstrap.js"; import { byoAppStartupNote, azLoginNotSignedInMessage } from "./onboarding-messages.js"; import { readState } from "./state.js"; -import { productUserAgent, isProductUserAgent } from "./user-agent.js"; +import { + configureAzureUserAgentEnvironment, + resolveAgentHostAttribution, + setAgentHostAttribution, + setInstallAttribution, +} from "./user-agent.js"; import { PACKAGE_VERSION } from "./version.js"; import type { McpTool, ServerConfig } from "./types.js"; import { createLogger } from "./logger.js"; @@ -241,6 +246,7 @@ function toListToolEntry(tool: McpTool) { * startServer(). See docs/SECURITY-CONTROLS.md for the control-code legend. */ let activePolicy: ResolvedToolPolicy | null = null; +let attributionEnabled = true; /** Tools advertised to the client, filtered by the active policy. */ function listVisibleTools() { @@ -274,6 +280,22 @@ const server = new Server( // importing the SDK request type throughout the codebase. wireElicitation(server as unknown as ElicitationCapableServer); +server.oninitialized = () => { + const agentHost = resolveAgentHostAttribution( + server.getClientVersion()?.name, + attributionEnabled, + ); + setAgentHostAttribution(agentHost); + // Graph reads the User-Agent lazily per request. Refresh the Azure CLI + // variables now so future az/azd child processes carry the host as well. + configureAzureUserAgentEnvironment(); + if (agentHost) { + console.error( + `[SPE MCP Server] Agent host attribution: ${agentHost} (self-reported MCP clientInfo)`, + ); + } +}; + server.setRequestHandler(ListToolsRequestSchema, async () => { const tools = listVisibleTools(); log(`ListTools request received`, { count: tools.length }); @@ -391,6 +413,10 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => { export async function startServer(config: ServerConfig) { log("Starting SharePoint Embedded MCP Server..."); + attributionEnabled = config.attributionEnabled ?? true; + setInstallAttribution( + attributionEnabled ? config.installAttribution : undefined, + ); // SAFE-003 (read-only mode) / SAFE-004 (tool allowlist): build the tool policy // once from config (read-only mode and/or an allowlist profile or CSV). When @@ -407,22 +433,10 @@ export async function startServer(config: ServerConfig) { ); } - // Stamp outbound `az` / `azd` traffic for aggregate attribution. The Azure - // CLI and Developer CLI append AZURE_HTTP_USER_AGENT to their User-Agent on - // every ARM request. Respect any value the user already set, and honor the - // `SPE_MCP_COLLECT_TELEMETRY` opt-out: when opted out, also strip a stale product - // token left in the environment by a prior (non-opted-out) run so the opt-out - // is reliably enforced. - const armUserAgent = productUserAgent(); - if (armUserAgent) { - if (!process.env.AZURE_HTTP_USER_AGENT) { - process.env.AZURE_HTTP_USER_AGENT = armUserAgent; - } - } else if (isProductUserAgent(process.env.AZURE_HTTP_USER_AGENT)) { - // Only our own product token is removed; an unrelated value the user set for - // their own attribution is left untouched. - delete process.env.AZURE_HTTP_USER_AGENT; - } + // Azure CLI (`az`) and Azure Developer CLI (`azd`) consume different + // User-Agent environment variables. Preserve unrelated caller values in both + // and remove all of this tool's tokens when telemetry is opted out. + configureAzureUserAgentEnvironment(); // Connect transport first so MCP `initialize` handshake works immediately const transport = new StdioServerTransport(); diff --git a/src/protocol-e2e.test.ts b/src/protocol-e2e.test.ts index 315a809..ed9df4a 100644 --- a/src/protocol-e2e.test.ts +++ b/src/protocol-e2e.test.ts @@ -54,6 +54,7 @@ describe("MCP protocol-level e2e (spawned dist/cli.js start)", () => { let client: Client; let transport: StdioClientTransport; let isolatedHome: string; + let serverStderr = ""; beforeAll(async () => { // Self-build guard: this suite drives the *built* server (dist/cli.js). The @@ -85,11 +86,15 @@ describe("MCP protocol-level e2e (spawned dist/cli.js start)", () => { args: [CLI_ENTRY, "start"], env, cwd: REPO_ROOT, - // Swallow the server's stderr diagnostics so they don't pollute test output. - stderr: "ignore", + // Capture diagnostics without printing them so the real initialize-driven + // agent-host classification can be asserted over the wire. + stderr: "pipe", + }); + transport.stderr?.on("data", (chunk) => { + serverStderr += String(chunk); }); - client = new Client({ name: "spe-mcp-e2e-test", version: "0.0.0" }, {}); + client = new Client({ name: "Visual Studio Code", version: "0.0.0-test" }, {}); // connect() performs the MCP `initialize` handshake. await client.connect(transport); }, 90000); @@ -121,6 +126,19 @@ describe("MCP protocol-level e2e (spawned dist/cli.js start)", () => { expect(client.getServerCapabilities()?.tools).toBeDefined(); }); + it("classifies clientInfo after the initialized notification", async () => { + const deadline = Date.now() + 2000; + while ( + !serverStderr.includes("Agent host attribution: vscode") && + Date.now() < deadline + ) { + await new Promise((resolveWait) => setTimeout(resolveWait, 20)); + } + expect(serverStderr).toContain( + "Agent host attribution: vscode (self-reported MCP clientInfo)", + ); + }); + // (a2) The `initialize` result carries the server `instructions` primer over // the real wire, so clients can prime the model before any tool call. it("returns the SPE domain primer via the initialize instructions field", () => { diff --git a/src/tools/deploy-azure.test.ts b/src/tools/deploy-azure.test.ts index 089700c..4d0d10d 100644 --- a/src/tools/deploy-azure.test.ts +++ b/src/tools/deploy-azure.test.ts @@ -89,25 +89,39 @@ describe("project_deploy", () => { it("runs azd up --no-prompt with env wired from state and returns the endpoint", async () => { stateStore.azureSubscriptionId = "sub-123"; stateStore.containerTypeId = "ct-456"; + const previousAzdUserAgent = process.env.AZURE_DEV_USER_AGENT; + process.env.AZURE_DEV_USER_AGENT = + "spe-mcp-server/test spe-install-source/github-readme"; + + try { + const r = await deployAzureTool.handler({ projectDir: "/proj", environmentName: "spe-demo", location: "eastus" }); + + expect(r.isError).toBeFalsy(); + expect(runCommand).toHaveBeenCalledTimes(1); + const [cmd, args, opts] = vi.mocked(runCommand).mock.calls[0] as unknown as [ + string, + string[], + { env?: NodeJS.ProcessEnv }, + ]; + expect(cmd).toBe("azd"); + expect(args).toEqual(["up", "--no-prompt", "--environment", "spe-demo"]); + expect(opts.env?.AZURE_ENV_NAME).toBe("spe-demo"); + expect(opts.env?.AZURE_LOCATION).toBe("eastus"); + expect(opts.env?.AZURE_SUBSCRIPTION_ID).toBe("sub-123"); + expect(opts.env?.SPE_CONTAINER_TYPE_ID).toBe("ct-456"); + expect(opts.env?.AZURE_DEV_USER_AGENT).toBe( + "spe-mcp-server/test spe-install-source/github-readme", + ); - const r = await deployAzureTool.handler({ projectDir: "/proj", environmentName: "spe-demo", location: "eastus" }); - - expect(r.isError).toBeFalsy(); - expect(runCommand).toHaveBeenCalledTimes(1); - const [cmd, args, opts] = vi.mocked(runCommand).mock.calls[0] as unknown as [ - string, - string[], - { env?: NodeJS.ProcessEnv }, - ]; - expect(cmd).toBe("azd"); - expect(args).toEqual(["up", "--no-prompt", "--environment", "spe-demo"]); - expect(opts.env?.AZURE_ENV_NAME).toBe("spe-demo"); - expect(opts.env?.AZURE_LOCATION).toBe("eastus"); - expect(opts.env?.AZURE_SUBSCRIPTION_ID).toBe("sub-123"); - expect(opts.env?.SPE_CONTAINER_TYPE_ID).toBe("ct-456"); - - expect(r.content[0].text).toContain("https://demo.happyrock-1.eastus.azurecontainerapps.io/"); - expect(r.content[0].text).toContain("subscription-scoped"); + expect(r.content[0].text).toContain("https://demo.happyrock-1.eastus.azurecontainerapps.io/"); + expect(r.content[0].text).toContain("subscription-scoped"); + } finally { + if (previousAzdUserAgent === undefined) { + delete process.env.AZURE_DEV_USER_AGENT; + } else { + process.env.AZURE_DEV_USER_AGENT = previousAzdUserAgent; + } + } }); it("passes a punctuation-heavy environment name to azd as one discrete argv element", async () => { diff --git a/src/types.ts b/src/types.ts index a0c3898..a58d36f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ import type { FileStorageContainer, Permission as GraphPermission, } from "@microsoft/microsoft-graph-types"; +import type { InstallAttribution } from "./user-agent.js"; // The SPE container-type CONTROL-PLANE contracts are Microsoft Graph **beta** // APIs, so their official types come from `@microsoft/microsoft-graph-types-beta` // — also a types-only `.d.ts` package pinned in devDependencies (zero runtime). @@ -104,6 +105,10 @@ export interface ServerConfig { * to see the profile list and description. */ tools?: string; + /** Optional, bounded install-source metadata added to existing Graph/ARM requests. */ + installAttribution?: InstallAttribution; + /** Whether install-source and self-reported agent-host attribution are enabled. */ + attributionEnabled?: boolean; } // ─── Auth Config ───────────────────────────────────────────────────────────── diff --git a/src/user-agent.test.ts b/src/user-agent.test.ts index 70565bb..cd86417 100644 --- a/src/user-agent.test.ts +++ b/src/user-agent.test.ts @@ -1,26 +1,196 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -/** - * Telemetry opt-out tests. - * - * The product `User-Agent` token is the only Microsoft-bound attribution signal - * this build emits, so it is gated behind `SPE_MCP_COLLECT_TELEMETRY`. It is ON by - * default and suppressed only when the variable is explicitly falsy. These tests - * pin that contract so the documented opt-out stays wired to real behavior. - */ -import { describe, it, expect, afterEach } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { - telemetryEnabled, - productUserAgent, - isProductUserAgent, - applyProductUserAgent, USER_AGENT, + __testing, + applyProductUserAgent, + appendUserAgent, + classifyAgentHost, + configureAzureUserAgentEnvironment, + getUserAgent, + isProductUserAgent, + productUserAgent, + resolveAgentHostAttribution, + resolveInstallAttribution, + setAgentHostAttribution, + setInstallAttribution, + telemetryEnabled, } from "./user-agent.js"; const saved = process.env.SPE_MCP_COLLECT_TELEMETRY; +describe("install attribution User-Agent", () => { + afterEach(() => { + __testing.reset(); + if (saved === undefined) { + delete process.env.SPE_MCP_COLLECT_TELEMETRY; + } else { + process.env.SPE_MCP_COLLECT_TELEMETRY = saved; + } + }); + + it("keeps the historical product-only value when attribution is absent", () => { + expect(resolveInstallAttribution({})).toBeUndefined(); + expect(getUserAgent()).toBe(USER_AGENT); + }); + + it("adds bounded source, content, and campaign tokens", () => { + const attribution = resolveInstallAttribution({ + source: "microsoft-learn", + content: "sharepoint-embedded-mcp-server", + campaign: "docs-install-buttons", + }); + + setInstallAttribution(attribution); + + expect(getUserAgent()).toBe( + `${USER_AGENT} spe-install-source/microsoft-learn ` + + "spe-install-content/sharepoint-embedded-mcp-server " + + "spe-install-campaign/docs-install-buttons", + ); + }); + + it.each([ + ["Visual Studio Code", "vscode"], + ["Visual Studio Code - Insiders", "vscode"], + ["Code - OSS", "vscode"], + ["Code - OSS Dev", "vscode"], + ["Cursor", "cursor"], + ["claude-code", "claude-code"], + ["Claude Code", "claude-code"], + ["Claude", "claude-desktop"], + ["Claude Desktop", "claude-desktop"], + ["claude-ai", "claude-desktop"], + ["local-agent-mode-spe", "claude-desktop"], + ["OpenAI Codex CLI", "codex"], + ["GitHub Copilot CLI", "github-copilot-cli"], + ["copilot-cli", "github-copilot-cli"], + ["github-copilot-developer", "github-copilot-cli"], + ["Microsoft Visual Studio", "visual-studio"], + ["Azure AI Foundry", "azure-ai-foundry"], + ["mcp", "unknown"], + ["", "unknown"], + ["Future MCP Host", "other"], + ])("classifies MCP clientInfo name %j as %s", (clientName, expected) => { + expect(classifyAgentHost(clientName)).toBe(expected); + }); + + it("adds only the bounded host classification, not raw clientInfo", () => { + setAgentHostAttribution(classifyAgentHost("Future MCP Host with user text")); + expect(getUserAgent()).toBe(`${USER_AGENT} spe-agent-host/other`); + expect(getUserAgent()).not.toContain("future"); + }); + + it("omits agent-host attribution when attribution is disabled", () => { + expect(resolveAgentHostAttribution("Visual Studio Code", false)).toBeUndefined(); + }); + + it("normalizes identifiers and rejects unbounded or unsupported values", () => { + expect( + resolveInstallAttribution({ + source: " Microsoft-Learn ", + content: " QuickStart-VSCode ", + }), + ).toEqual({ source: "microsoft-learn", content: "quickstart-vscode" }); + expect(() => resolveInstallAttribution({ source: "reddit" })).toThrow( + /must be one of/i, + ); + expect(() => + resolveInstallAttribution({ source: "microsoft-learn", content: "contains spaces" }), + ).toThrow(/1-64 character/i); + expect(() => + resolveInstallAttribution({ source: "microsoft-learn", content: "unpublished-doc" }), + ).toThrow(/must be one of/i); + expect(() => + resolveInstallAttribution({ + source: "microsoft-learn", + campaign: "unreviewed-campaign", + }), + ).toThrow(/must be one of/i); + expect(() => resolveInstallAttribution({ content: "mcp-server" })).toThrow( + /require --install-source/i, + ); + }); + + it("honors the explicit opt-out before validating configured values", () => { + expect( + resolveInstallAttribution({ + source: "not-supported", + content: "contains spaces", + enabled: false, + }), + ).toBeUndefined(); + }); + + it("preserves an existing Azure CLI User-Agent while avoiding duplicates", () => { + expect(appendUserAgent(undefined, USER_AGENT)).toBe(USER_AGENT); + expect(appendUserAgent("caller/1.0", USER_AGENT)).toBe(`caller/1.0 ${USER_AGENT}`); + expect(appendUserAgent(`caller/1.0 ${USER_AGENT}`, USER_AGENT)).toBe( + `caller/1.0 ${USER_AGENT}`, + ); + expect( + appendUserAgent( + `caller/1.0 ${USER_AGENT} spe-install-source/microsoft-learn`, + `${USER_AGENT} spe-install-source/github-readme`, + ), + ).toBe(`caller/1.0 ${USER_AGENT} spe-install-source/github-readme`); + }); + + it("configures both az and azd User-Agent environment variables", () => { + setInstallAttribution( + resolveInstallAttribution({ + source: "github-readme", + content: "readme-install", + campaign: "docs-install-buttons", + }), + ); + setAgentHostAttribution("vscode"); + const env: NodeJS.ProcessEnv = { + AZURE_HTTP_USER_AGENT: "existing-az/1.0", + AZURE_DEV_USER_AGENT: "existing-azd/1.0", + }; + + configureAzureUserAgentEnvironment(env); + + expect(env.AZURE_HTTP_USER_AGENT).toMatch( + /^existing-az\/1\.0 spe-mcp-server\/\S+ spe-install-source\/github-readme.*spe-agent-host\/vscode/, + ); + expect(env.AZURE_DEV_USER_AGENT).toMatch( + /^existing-azd\/1\.0 spe-mcp-server\/\S+ spe-install-source\/github-readme.*spe-agent-host\/vscode/, + ); + }); + + it("suppresses all attribution tokens on telemetry opt-out", () => { + setInstallAttribution( + resolveInstallAttribution({ source: "github-readme", content: "readme-install" }), + ); + setAgentHostAttribution("vscode"); + process.env.SPE_MCP_COLLECT_TELEMETRY = "false"; + const env: NodeJS.ProcessEnv = { + AZURE_HTTP_USER_AGENT: + "caller/1.0 spe-mcp-server/old spe-install-source/microsoft-learn spe-agent-host/cursor", + AZURE_DEV_USER_AGENT: "spe-mcp-server/old spe-install-campaign/docs-install-buttons", + }; + + expect(getUserAgent()).toBeUndefined(); + configureAzureUserAgentEnvironment(env); + + expect(env.AZURE_HTTP_USER_AGENT).toBe("caller/1.0"); + expect(env.AZURE_DEV_USER_AGENT).toBeUndefined(); + }); +}); + +/** + * Telemetry opt-out tests. + * + * All attribution tokens are gated behind `SPE_MCP_COLLECT_TELEMETRY`. They are + * on by default and suppressed only when the variable is explicitly falsy. + */ + afterEach(() => { + __testing.reset(); if (saved === undefined) { delete process.env.SPE_MCP_COLLECT_TELEMETRY; } else { @@ -75,20 +245,33 @@ describe("applyProductUserAgent (opt-out enforcement)", () => { expect(headers["User-Agent"]).toBe(USER_AGENT); }); - it("does not overwrite a caller-supplied User-Agent when on", () => { + it("preserves a caller-supplied User-Agent and appends attribution when on", () => { delete process.env.SPE_MCP_COLLECT_TELEMETRY; const headers = applyProductUserAgent({ "User-Agent": "caller/1.0" }); - expect(headers["User-Agent"]).toBe("caller/1.0"); + expect(headers["User-Agent"]).toBe(`caller/1.0 ${USER_AGENT}`); }); - it("strips any User-Agent (both casings) when opted out", () => { + it("replaces stale owned tokens without duplicating them", () => { + setInstallAttribution(resolveInstallAttribution({ source: "github-readme" })); + const headers = applyProductUserAgent({ + "User-agent": `caller/1.0 ${USER_AGENT} spe-install-source/microsoft-learn`, + "USER-AGENT": "caller/1.0 extension/2.0", + }); + + expect(headers["User-Agent"]).toBe( + `caller/1.0 extension/2.0 ${USER_AGENT} spe-install-source/github-readme`, + ); + expect(headers["User-agent"]).toBeUndefined(); + expect(headers["USER-AGENT"]).toBeUndefined(); + }); + + it("strips owned tokens but preserves unrelated User-Agent content when opted out", () => { process.env.SPE_MCP_COLLECT_TELEMETRY = "false"; const headers = applyProductUserAgent({ Authorization: "Bearer x", - "User-Agent": "caller/1.0", - "user-agent": "caller/1.0", + "user-agent": `caller/1.0 ${USER_AGENT} spe-agent-host/vscode`, }); - expect(headers["User-Agent"]).toBeUndefined(); + expect(headers["User-Agent"]).toBe("caller/1.0"); expect(headers["user-agent"]).toBeUndefined(); // Unrelated headers are left intact. expect(headers.Authorization).toBe("Bearer x"); diff --git a/src/user-agent.ts b/src/user-agent.ts index 234f603..218241e 100644 --- a/src/user-agent.ts +++ b/src/user-agent.ts @@ -2,42 +2,189 @@ // Licensed under the MIT license. /** - * Static product identifier stamped on outbound Microsoft Graph and Azure CLI - * (`az` / `azd`) requests for aggregate traffic attribution. + * Product and optional bounded attribution identifiers stamped on outbound + * Microsoft Graph and Azure CLI (`az` / `azd`) requests. * - * This is a constant product/version token. It carries NO per-user, per-tenant, - * or personal data, opens NO separate telemetry channel, and rides only on the - * Graph/ARM calls the tool already makes on the user's behalf (e.g. creating a - * container type). The SharePoint Embedded service can filter request logs on - * this token to measure how much traffic this tool drives. - * - * Because it is the only Microsoft-bound attribution signal this build emits, - * it is treated as telemetry for opt-out purposes: it is ON by default and is - * suppressed via the `SPE_MCP_COLLECT_TELEMETRY` environment variable (see - * {@link telemetryEnabled} / {@link productUserAgent}). - * - * The version segment is derived from package.json (the single source of truth) - * via {@link PACKAGE_VERSION}, so it can never drift out of sync on release. + * They carry no per-user, per-tenant, or personal data, open no separate + * telemetry channel, and ride only on calls the tool already makes. All tokens + * are suppressed by the `SPE_MCP_COLLECT_TELEMETRY` opt-out. */ +import { ValidationError } from "./errors.js"; import { PACKAGE_VERSION } from "./version.js"; -/** - * Product-name segment of {@link USER_AGENT}, without the version. Kept as a - * single source of truth so the emitted token and the prefix checks used to - * recognize/strip it (see {@link isProductUserAgent}) can never drift apart. - */ const PRODUCT_NAME = "spe-mcp-server"; export const USER_AGENT = `${PRODUCT_NAME}/${PACKAGE_VERSION}`; +export const INSTALL_SOURCES = [ + "microsoft-learn", + "github-readme", + "github-release", + "mcp-registry", + "npm", + "other", +] as const; + +export type InstallSource = (typeof INSTALL_SOURCES)[number]; + +export const INSTALL_CONTENTS = [ + "readme-install", + "sharepoint-embedded-mcp-server", + "quickstart-vscode", + "create-container-type", + "create-manage-containers", +] as const; + +export const INSTALL_CAMPAIGNS = ["docs-install-buttons"] as const; + +export const AGENT_HOSTS = [ + "vscode", + "visual-studio", + "cursor", + "claude-code", + "claude-desktop", + "codex", + "github-copilot-cli", + "azure-ai-foundry", + "other", + "unknown", +] as const; + +export type AgentHost = (typeof AGENT_HOSTS)[number]; + +export interface InstallAttribution { + source: InstallSource; + content?: (typeof INSTALL_CONTENTS)[number]; + campaign?: (typeof INSTALL_CAMPAIGNS)[number]; +} + +export interface InstallAttributionInput { + source?: string; + content?: string; + campaign?: string; + enabled?: boolean; +} + +const ATTRIBUTION_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/; +let activeAttribution: InstallAttribution | undefined; +let activeAgentHost: AgentHost | undefined; + +function normalizeOptionalId(value: string | undefined, field: string): string | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return undefined; + if (!ATTRIBUTION_ID_PATTERN.test(normalized)) { + throw new ValidationError( + `${field} must be a 1-64 character lowercase identifier using only letters, numbers, '.', '_' or '-'.`, + ); + } + return normalized; +} + +export function resolveInstallAttribution( + input: InstallAttributionInput, +): InstallAttribution | undefined { + if (input.enabled === false) return undefined; + + const source = normalizeOptionalId(input.source, "install source"); + const content = normalizeOptionalId(input.content, "install content"); + const campaign = normalizeOptionalId(input.campaign, "install campaign"); + + if (!source) { + if (content || campaign) { + throw new ValidationError( + "--install-content and --install-campaign require --install-source.", + ); + } + return undefined; + } + + if (!INSTALL_SOURCES.includes(source as InstallSource)) { + throw new ValidationError( + `install source must be one of: ${INSTALL_SOURCES.join(", ")}.`, + ); + } + if ( + content && + !INSTALL_CONTENTS.includes(content as (typeof INSTALL_CONTENTS)[number]) + ) { + throw new ValidationError( + `install content must be one of: ${INSTALL_CONTENTS.join(", ")}.`, + ); + } + if ( + campaign && + !INSTALL_CAMPAIGNS.includes(campaign as (typeof INSTALL_CAMPAIGNS)[number]) + ) { + throw new ValidationError( + `install campaign must be one of: ${INSTALL_CAMPAIGNS.join(", ")}.`, + ); + } + + return { + source: source as InstallSource, + ...(content + ? { content: content as (typeof INSTALL_CONTENTS)[number] } + : {}), + ...(campaign + ? { campaign: campaign as (typeof INSTALL_CAMPAIGNS)[number] } + : {}), + }; +} + +export function setInstallAttribution(attribution: InstallAttribution | undefined): void { + activeAttribution = attribution; +} + /** - * Whether the product `User-Agent` attribution token should be stamped on - * outbound Graph/ARM requests. - * - * Attribution is ON by default and is opted out by setting - * `SPE_MCP_COLLECT_TELEMETRY` to a falsy value (`false`, `0`, `no`, or `off`, - * case-insensitive). Any other value — or leaving it unset — keeps it on. + * Classify the self-reported MCP `initialize.params.clientInfo.name` into a + * bounded analytics dimension. This is advisory attribution only, never a + * security signal. Unknown raw values are not transmitted. */ +export function classifyAgentHost(clientName: string | undefined): AgentHost { + const name = clientName?.trim().toLowerCase() ?? ""; + if (!name || name === "mcp") return "unknown"; + if ( + name.includes("visual studio code") || + name.startsWith("code - oss") + ) { + return "vscode"; + } + if (name.includes("cursor")) return "cursor"; + if (name === "claude-code" || name.includes("claude code")) { + return "claude-code"; + } + if ( + name === "claude" || + name === "claude-ai" || + name.includes("claude desktop") || + name.startsWith("local-agent-mode-") + ) { + return "claude-desktop"; + } + if ( + name.includes("github copilot cli") || + name.includes("copilot-cli") || + name === "github-copilot-developer" + ) { + return "github-copilot-cli"; + } + if (name.includes("codex")) return "codex"; + if (name.includes("visual studio")) return "visual-studio"; + if (name.includes("foundry")) return "azure-ai-foundry"; + return "other"; +} + +export function resolveAgentHostAttribution( + clientName: string | undefined, + enabled: boolean, +): AgentHost | undefined { + return enabled ? classifyAgentHost(clientName) : undefined; +} + +export function setAgentHostAttribution(agentHost: AgentHost | undefined): void { + activeAgentHost = agentHost; +} + export function telemetryEnabled( value: string | undefined = process.env.SPE_MCP_COLLECT_TELEMETRY, ): boolean { @@ -45,47 +192,84 @@ export function telemetryEnabled( return !["0", "false", "no", "off"].includes(value.trim().toLowerCase()); } -/** - * The product `User-Agent` token to stamp on outbound requests, or `undefined` - * when attribution is opted out via {@link telemetryEnabled}. Callers omit the - * header entirely when this returns `undefined`. - */ export function productUserAgent(): string | undefined { return telemetryEnabled() ? USER_AGENT : undefined; } -/** - * Whether `value` is this tool's product attribution token, for any version - * (i.e. `spe-mcp-server/`). Used to recognize — and strip on opt-out — - * a token this process may have left in the environment on a prior run, without - * disturbing any unrelated value the user set for their own attribution. - */ export function isProductUserAgent(value: string | undefined): boolean { return typeof value === "string" && value.startsWith(`${PRODUCT_NAME}/`); } -/** - * Apply the product `User-Agent` attribution policy to an outbound header set, - * mutating and returning it. - * - * - Attribution ON: stamp the product token, but only when the caller has not - * already supplied a `User-Agent` (a caller-supplied header still wins, - * preserving prior precedence). - * - Opted out: guarantee no `User-Agent` survives — including one supplied by a - * caller — so the documented opt-out cannot be bypassed, accidentally or by a - * future call site. Both header-name casings are removed. - */ +export function getUserAgent(): string | undefined { + if (!telemetryEnabled()) return undefined; + + const tokens: string[] = []; + if (activeAttribution) { + tokens.push(`spe-install-source/${activeAttribution.source}`); + if (activeAttribution.content) { + tokens.push(`spe-install-content/${activeAttribution.content}`); + } + if (activeAttribution.campaign) { + tokens.push(`spe-install-campaign/${activeAttribution.campaign}`); + } + } + if (activeAgentHost) { + tokens.push(`spe-agent-host/${activeAgentHost}`); + } + return tokens.length > 0 ? `${USER_AGENT} ${tokens.join(" ")}` : USER_AGENT; +} + +function isOwnedUserAgentToken(token: string): boolean { + return ( + isProductUserAgent(token) || + token.startsWith("spe-install-source/") || + token.startsWith("spe-install-content/") || + token.startsWith("spe-install-campaign/") || + token.startsWith("spe-agent-host/") + ); +} + +export function appendUserAgent( + existing: string | undefined, + value: string | undefined, +): string | undefined { + const currentTokens = existing?.trim().split(/\s+/).filter(Boolean) ?? []; + const preserved = currentTokens.filter((token) => !isOwnedUserAgentToken(token)); + const combined = [ + ...new Set([...preserved, ...(value ? value.split(/\s+/) : [])]), + ].join(" "); + return combined || undefined; +} + export function applyProductUserAgent( headers: Record, ): Record { - const ua = productUserAgent(); - if (ua) { - if (headers["User-Agent"] === undefined && headers["user-agent"] === undefined) { - headers["User-Agent"] = ua; - } - } else { - delete headers["User-Agent"]; - delete headers["user-agent"]; - } + const userAgentKeys = Object.keys(headers).filter( + (key) => key.toLowerCase() === "user-agent", + ); + const existing = + userAgentKeys.map((key) => headers[key]).filter(Boolean).join(" ") || undefined; + const userAgent = appendUserAgent(existing, getUserAgent()); + for (const key of userAgentKeys) delete headers[key]; + if (userAgent) headers["User-Agent"] = userAgent; return headers; } + +export function configureAzureUserAgentEnvironment( + env: NodeJS.ProcessEnv = process.env, +): void { + const userAgent = getUserAgent(); + const azureUserAgent = appendUserAgent(env.AZURE_HTTP_USER_AGENT, userAgent); + const azureDevUserAgent = appendUserAgent(env.AZURE_DEV_USER_AGENT, userAgent); + if (azureUserAgent) env.AZURE_HTTP_USER_AGENT = azureUserAgent; + else delete env.AZURE_HTTP_USER_AGENT; + if (azureDevUserAgent) env.AZURE_DEV_USER_AGENT = azureDevUserAgent; + else delete env.AZURE_DEV_USER_AGENT; +} + +export const __testing = { + reset(): void { + activeAttribution = undefined; + activeAgentHost = undefined; + }, +}; diff --git a/src/version.test.ts b/src/version.test.ts index 4287152..60278e8 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -17,7 +17,7 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { PACKAGE_VERSION } from "./version.js"; -import { USER_AGENT } from "./user-agent.js"; +import { USER_AGENT, getUserAgent } from "./user-agent.js"; const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const pkgVersion = ( @@ -35,6 +35,7 @@ describe("version: single source of truth", () => { it("derives USER_AGENT from package.json in the spe-mcp-server/ format", () => { expect(USER_AGENT).toBe(`spe-mcp-server/${pkgVersion}`); + expect(getUserAgent()).toBe(USER_AGENT); }); it("keeps the MCP registry manifest aligned with package.json", () => {