From a7b933bf30717c3edfb9f0a24cae87b66380ca50 Mon Sep 17 00:00:00 2001 From: grjoseph Date: Mon, 10 Aug 2026 12:29:27 -0700 Subject: [PATCH 1/4] feat: add bounded install-source attribution Carry approved source, content, and campaign labels from MCP install configurations on existing Graph and ARM requests. Add an explicit opt-out, attributed README install buttons, privacy disclosures, and regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 ++ PRIVACY.md | 27 ++++--- README.md | 80 ++++++++++++++++++--- docs/DATA-FLOW.md | 7 +- src/cli.ts | 47 +++++++++++- src/graph-client.test.ts | 34 ++++++++- src/graph-client.ts | 4 +- src/index.ts | 16 +++-- src/types.ts | 3 + src/user-agent.test.ts | 89 +++++++++++++++++++++++ src/user-agent.ts | 151 ++++++++++++++++++++++++++++++++++++--- src/version.test.ts | 3 +- 12 files changed, 419 insertions(+), 47 deletions(-) create mode 100644 src/user-agent.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ec93124..e41d06a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ 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`. `--no-install-attribution` provides an + explicit opt-out. 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/PRIVACY.md b/PRIVACY.md index d3624c6..48f58bc 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -9,8 +9,8 @@ organization's agreements with Microsoft. ## What the tool collects and sends -**The tool does not collect telemetry or usage analytics, and it opens no dedicated channel -to send data to Microsoft.** Specifically: +**The tool does not collect event-level usage analytics, and it opens no dedicated +telemetry channel to send data to Microsoft.** Specifically: - **No telemetry channel.** The tool does not implement application telemetry and does not "phone home." Diagnostic logs are written to the local process's **stderr only**, with @@ -24,11 +24,12 @@ to send data to Microsoft.** Specifically: 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. +- **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 labels contain **no personal or tenant identifiers** and exist only so the + service can measure aggregate traffic driven by published install surfaces. They ride + on calls you already make and are not a separate data feed. See [docs/DATA-FLOW.md](docs/DATA-FLOW.md) for the full list of network endpoints and what travels to each. @@ -51,7 +52,11 @@ terms, which are outside the control of this project. ## Turning it off -Because the tool has no telemetry channel, there is nothing to opt out of. To further limit -outbound calls you can run with `--read-only` (no mutating operations) or `--tools` (restrict -the exposed tool set, including the optional Microsoft Learn documentation lookup). See -[docs/DATA-FLOW.md](docs/DATA-FLOW.md) and [docs/SECURITY-CONTROLS.md](docs/SECURITY-CONTROLS.md). +Because the tool has no telemetry channel, there is no separate telemetry stream to opt out +of. To omit install-source labels from existing API requests, remove the +`--install-source`, `--install-content`, and `--install-campaign` arguments from the MCP +client configuration or add `--no-install-attribution`. To further limit outbound calls, +run with `--read-only` (no mutating operations) or `--tools` (restrict the exposed tool +set, including the optional Microsoft Learn documentation lookup). See +[docs/DATA-FLOW.md](docs/DATA-FLOW.md) and +[docs/SECURITY-CONTROLS.md](docs/SECURITY-CONTROLS.md). diff --git a/README.md b/README.md index eb3bc87..02607fb 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,17 @@ 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://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22%3A%22spe%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40microsoft%2Fspe-mcp%22%2C%22start%22%2C%22--install-source%22%2C%22github-readme%22%2C%22--install-content%22%2C%22readme-install%22%2C%22--install-campaign%22%2C%22docs-install-buttons%22%5D%7D) + +One-click install is also available for [Visual Studio Code Insiders](https://vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22name%22%3A%22spe%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40microsoft%2Fspe-mcp%22%2C%22start%22%2C%22--install-source%22%2C%22github-readme%22%2C%22--install-content%22%2C%22readme-install%22%2C%22--install-campaign%22%2C%22docs-install-buttons%22%5D%7D), [Visual Studio](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22spe%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40microsoft%2Fspe-mcp%22%2C%22start%22%2C%22--install-source%22%2C%22github-readme%22%2C%22--install-content%22%2C%22readme-install%22%2C%22--install-campaign%22%2C%22docs-install-buttons%22%5D%7D), and [Cursor](https://cursor.com/install-mcp?name=spe&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBtaWNyb3NvZnQvc3BlLW1jcCIsInN0YXJ0IiwiLS1pbnN0YWxsLXNvdXJjZSIsImdpdGh1Yi1yZWFkbWUiLCItLWluc3RhbGwtY29udGVudCIsInJlYWRtZS1pbnN0YWxsIiwiLS1pbnN0YWxsLWNhbXBhaWduIiwiZG9jcy1pbnN0YWxsLWJ1dHRvbnMiXX0). 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. + ### VS Code / Cursor Add an MCP server entry to `.vscode/mcp.json` (VS Code) or your Cursor MCP @@ -82,7 +93,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" + ] } } } @@ -100,7 +121,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" + ] } } } @@ -191,6 +222,10 @@ 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 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 | > The CLI flag wins when both a flag and its env var are set. Run @@ -271,7 +306,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] @@ -289,6 +324,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 labels even when they are present in the client configuration or environment. | ## Authentication @@ -335,12 +374,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": "" } } } @@ -586,11 +645,12 @@ 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. Authentication tokens are cached locally with -owner-only permissions (**SEC-003**). For details see [PRIVACY.md](PRIVACY.md) and +The server opens **no separate telemetry channel** and sends **no event-level usage +analytics** to Microsoft. Outbound Graph/ARM requests carry a product `User-Agent` +(`spe-mcp-server/`). An install configuration can add bounded source, content, +and campaign labels; these contain no personal or tenant identifiers and can be omitted +with `--no-install-attribution`. 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). diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md index 8dbb395..72a371e 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -48,6 +48,7 @@ These never leave your machine: ## Telemetry -The server opens **no telemetry channel** and sends **no usage analytics**. Outbound requests -carry only a static product `User-Agent` (`spe-mcp-server/`) with no personal, -tenant, or usage data. See [PRIVACY.md](../PRIVACY.md) for details. +The server opens **no telemetry channel** and sends **no event-level usage analytics**. +Outbound requests carry a product `User-Agent` (`spe-mcp-server/`). Install configurations can add +bounded source, content, and campaign labels with no personal or tenant identifiers; users +can omit them with `--no-install-attribution`. See [PRIVACY.md](../PRIVACY.md) for details. diff --git a/src/cli.ts b/src/cli.ts index 0e3e73d..6e127dd 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 metadata to outbound requests, even when install attribution is configured.", + ) .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,23 @@ 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 }); } 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..3c5d3fc 100644 --- a/src/graph-client.test.ts +++ b/src/graph-client.test.ts @@ -25,6 +25,11 @@ import { desiredGraphResourceAccess, LOCAL_SPA_REDIRECT_URI, } from "./graph-client.js"; +import { + __testing as userAgentTesting, + resolveInstallAttribution, + 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 +117,41 @@ 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", + }), + ); + + 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/, + ); + }); +}); + /** Parse the requiredResourceAccess PATCH body from the Nth fetch call. */ function patchedRequiredResourceAccess(callIndex: number): RequiredResourceAccess[] { const call = fetchMock.mock.calls[callIndex]; @@ -751,4 +784,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 c82fd02..e241312 100644 --- a/src/graph-client.ts +++ b/src/graph-client.ts @@ -15,7 +15,7 @@ import { LOCAL_SPA_REDIRECT_URI } from "./constants.js"; import { AppError } from "./errors.js"; import { parseRetryAfterMs } from "./http-client.js"; import { readState, writeState } from "./state.js"; -import { USER_AGENT } from "./user-agent.js"; +import { getUserAgent } from "./user-agent.js"; import type { ApplicationPermissionGrant, Container, @@ -150,7 +150,7 @@ async function graphRequest( const headers: Record = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", - "User-Agent": USER_AGENT, + "User-Agent": getUserAgent(), ...customHeaders, }; diff --git a/src/index.ts b/src/index.ts index 519f332..d2bfc94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,7 @@ 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 { USER_AGENT } from "./user-agent.js"; +import { appendUserAgent, getUserAgent, setInstallAttribution } from "./user-agent.js"; import { PACKAGE_VERSION } from "./version.js"; import type { McpTool, ServerConfig } from "./types.js"; import { createLogger } from "./logger.js"; @@ -391,6 +391,7 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => { export async function startServer(config: ServerConfig) { log("Starting SharePoint Embedded MCP Server..."); + setInstallAttribution(config.installAttribution); // 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,12 +408,13 @@ 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. - if (!process.env.AZURE_HTTP_USER_AGENT) { - process.env.AZURE_HTTP_USER_AGENT = USER_AGENT; - } + // The Azure CLI and Developer CLI append AZURE_HTTP_USER_AGENT to every ARM + // request. Preserve any caller-supplied value while adding this product's + // bounded product/install tokens. + process.env.AZURE_HTTP_USER_AGENT = appendUserAgent( + process.env.AZURE_HTTP_USER_AGENT, + getUserAgent(), + ); // Connect transport first so MCP `initialize` handshake works immediately const transport = new StdioServerTransport(); diff --git a/src/types.ts b/src/types.ts index a0c3898..bb2ff3a 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,8 @@ 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; } // ─── Auth Config ───────────────────────────────────────────────────────────── diff --git a/src/user-agent.test.ts b/src/user-agent.test.ts new file mode 100644 index 0000000..df06d65 --- /dev/null +++ b/src/user-agent.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { afterEach, describe, expect, it } from "vitest"; +import { + USER_AGENT, + __testing, + appendUserAgent, + getUserAgent, + resolveInstallAttribution, + setInstallAttribution, +} from "./user-agent.js"; + +describe("install attribution User-Agent", () => { + afterEach(() => { + __testing.reset(); + }); + + 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("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`); + }); +}); diff --git a/src/user-agent.ts b/src/user-agent.ts index 29ba213..f9fa27f 100644 --- a/src/user-agent.ts +++ b/src/user-agent.ts @@ -2,18 +2,149 @@ // 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 install-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. - * - * 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. + * The install fields are deliberately bounded, non-personal labels supplied by + * the MCP client configuration. They open no separate telemetry channel and ride + * only on API calls the tool already makes on the user's behalf. Omitting the + * install arguments keeps the historical product/version-only User-Agent. */ +import { ValidationError } from "./errors.js"; import { PACKAGE_VERSION } from "./version.js"; export const USER_AGENT = `spe-mcp-server/${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 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; + +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; +} + +export function getUserAgent(): string { + if (!activeAttribution) return USER_AGENT; + + const tokens = [`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}`); + } + return `${USER_AGENT} ${tokens.join(" ")}`; +} + +export function appendUserAgent(existing: string | undefined, value: string): string { + const currentTokens = existing?.trim().split(/\s+/).filter(Boolean) ?? []; + const preserved = currentTokens.filter( + (token) => + !token.startsWith("spe-mcp-server/") && + !token.startsWith("spe-install-source/") && + !token.startsWith("spe-install-content/") && + !token.startsWith("spe-install-campaign/"), + ); + return [...preserved, value].join(" "); +} + +export const __testing = { + reset(): void { + activeAttribution = undefined; + }, +}; diff --git a/src/version.test.ts b/src/version.test.ts index 706e3fa..94d6c1d 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 = ( @@ -31,5 +31,6 @@ 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); }); }); From 8c8084691b5bb9d028ddce4825663739c1949401 Mon Sep 17 00:00:00 2001 From: grjoseph Date: Mon, 10 Aug 2026 13:01:32 -0700 Subject: [PATCH 2/4] fix: propagate attribution to azd requests Set both Azure CLI User-Agent environment variables, verify the azd child environment, and clarify that attribution labels accompany authenticated service requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PRIVACY.md | 10 +++---- README.md | 11 +++---- docs/DATA-FLOW.md | 10 ++++--- src/index.ts | 15 +++++----- src/tools/deploy-azure.test.ts | 52 +++++++++++++++++++++------------- src/user-agent.test.ts | 24 ++++++++++++++++ src/user-agent.ts | 14 +++++++++ 7 files changed, 95 insertions(+), 41 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 48f58bc..5a693cd 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -9,8 +9,7 @@ organization's agreements with Microsoft. ## What the tool collects and sends -**The tool does not collect event-level usage analytics, and it opens no dedicated -telemetry channel to send data to Microsoft.** Specifically: +**The tool opens no dedicated telemetry channel to send data to Microsoft.** Specifically: - **No telemetry channel.** The tool does not implement application telemetry and does not "phone home." Diagnostic logs are written to the local process's **stderr only**, with @@ -27,9 +26,10 @@ telemetry channel to send data to Microsoft.** Specifically: - **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 labels contain **no personal or tenant identifiers** and exist only so the - service can measure aggregate traffic driven by published install surfaces. They ride - on calls you already make and are not a separate data feed. + slug. The 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; they are not a separate data feed. See [docs/DATA-FLOW.md](docs/DATA-FLOW.md) for the full list of network endpoints and what travels to each. diff --git a/README.md b/README.md index 02607fb..9d0ab4b 100644 --- a/README.md +++ b/README.md @@ -645,11 +645,12 @@ 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 event-level usage -analytics** to Microsoft. Outbound Graph/ARM requests carry a product `User-Agent` -(`spe-mcp-server/`). An install configuration can add bounded source, content, -and campaign labels; these contain no personal or tenant identifiers and can be omitted -with `--no-install-attribution`. Authentication tokens are cached locally with owner-only +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, and campaign labels to that request header. The labels +contain no personal or tenant identifiers, but Microsoft services can associate them +with the authenticated request in normal service logs. Omit them with +`--no-install-attribution`. 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 diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md index 72a371e..9d86dee 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -48,7 +48,9 @@ These never leave your machine: ## Telemetry -The server opens **no telemetry channel** and sends **no event-level usage analytics**. -Outbound requests carry a product `User-Agent` (`spe-mcp-server/`). Install configurations can add -bounded source, content, and campaign labels with no personal or tenant identifiers; users -can omit them with `--no-install-attribution`. See [PRIVACY.md](../PRIVACY.md) for details. +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, but Microsoft services can associate them with the +authenticated request in normal service logs. Users can omit them with +`--no-install-attribution`. See [PRIVACY.md](../PRIVACY.md) for details. diff --git a/src/index.ts b/src/index.ts index d2bfc94..2a38faf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,10 @@ 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 { appendUserAgent, getUserAgent, setInstallAttribution } from "./user-agent.js"; +import { + configureAzureUserAgentEnvironment, + setInstallAttribution, +} from "./user-agent.js"; import { PACKAGE_VERSION } from "./version.js"; import type { McpTool, ServerConfig } from "./types.js"; import { createLogger } from "./logger.js"; @@ -408,13 +411,9 @@ export async function startServer(config: ServerConfig) { ); } - // The Azure CLI and Developer CLI append AZURE_HTTP_USER_AGENT to every ARM - // request. Preserve any caller-supplied value while adding this product's - // bounded product/install tokens. - process.env.AZURE_HTTP_USER_AGENT = appendUserAgent( - process.env.AZURE_HTTP_USER_AGENT, - getUserAgent(), - ); + // Azure CLI (`az`) and Azure Developer CLI (`azd`) consume different + // User-Agent environment variables. Preserve caller values in both. + configureAzureUserAgentEnvironment(); // Connect transport first so MCP `initialize` handshake works immediately const transport = new StdioServerTransport(); diff --git a/src/tools/deploy-azure.test.ts b/src/tools/deploy-azure.test.ts index 9dd4d45..60548d7 100644 --- a/src/tools/deploy-azure.test.ts +++ b/src/tools/deploy-azure.test.ts @@ -78,25 +78,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 r = await deployAzureTool.handler({ projectDir: "/proj", environmentName: "spe-demo", location: "eastus" }); - - expect(r.isError).toBeFalsy(); - expect(execFile).toHaveBeenCalledTimes(1); - const [cmd, args, opts] = vi.mocked(execFile).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"); + 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(execFile).toHaveBeenCalledTimes(1); + const [cmd, args, opts] = vi.mocked(execFile).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", + ); + + 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("retries the deploy alone when azd up loses the Resource Graph indexing race", async () => { diff --git a/src/user-agent.test.ts b/src/user-agent.test.ts index df06d65..8542cd9 100644 --- a/src/user-agent.test.ts +++ b/src/user-agent.test.ts @@ -6,6 +6,7 @@ import { USER_AGENT, __testing, appendUserAgent, + configureAzureUserAgentEnvironment, getUserAgent, resolveInstallAttribution, setInstallAttribution, @@ -86,4 +87,27 @@ describe("install attribution User-Agent", () => { ), ).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", + }), + ); + 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/, + ); + expect(env.AZURE_DEV_USER_AGENT).toMatch( + /^existing-azd\/1\.0 spe-mcp-server\/\S+ spe-install-source\/github-readme/, + ); + }); }); diff --git a/src/user-agent.ts b/src/user-agent.ts index f9fa27f..f76ade7 100644 --- a/src/user-agent.ts +++ b/src/user-agent.ts @@ -143,6 +143,20 @@ export function appendUserAgent(existing: string | undefined, value: string): st return [...preserved, value].join(" "); } +export function configureAzureUserAgentEnvironment( + env: NodeJS.ProcessEnv = process.env, +): void { + const userAgent = getUserAgent(); + env.AZURE_HTTP_USER_AGENT = appendUserAgent( + env.AZURE_HTTP_USER_AGENT, + userAgent, + ); + env.AZURE_DEV_USER_AGENT = appendUserAgent( + env.AZURE_DEV_USER_AGENT, + userAgent, + ); +} + export const __testing = { reset(): void { activeAttribution = undefined; From ebee85fae8adc8d0ca3668aaadb0d78a267b1176 Mon Sep 17 00:00:00 2001 From: grjoseph Date: Mon, 10 Aug 2026 15:00:08 -0700 Subject: [PATCH 3/4] feat: add MCP agent-host attribution Classify self-reported MCP clientInfo into bounded host values after initialization, propagate the label to Graph, az, and azd requests, honor the existing attribution opt-out, and document the trust and privacy boundaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 7 +++- PRIVACY.md | 13 +++--- README.md | 18 +++++--- docs/DATA-FLOW.md | 6 ++- src/cli.ts | 9 +++- src/graph-client.test.ts | 4 +- src/index.ts | 24 ++++++++++- src/protocol-e2e.test.ts | 24 +++++++++-- src/types.ts | 2 + src/user-agent.test.ts | 44 +++++++++++++++++++- src/user-agent.ts | 90 ++++++++++++++++++++++++++++++++++++---- 11 files changed, 210 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e41d06a..0570dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **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`. `--no-install-attribution` provides an - explicit opt-out. The README install links declare `github-readme` as their source. + 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/PRIVACY.md b/PRIVACY.md index 5a693cd..a1d080b 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -26,10 +26,12 @@ organization's agreements with Microsoft. - **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 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; they are not a separate data feed. + 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. See [docs/DATA-FLOW.md](docs/DATA-FLOW.md) for the full list of network endpoints and what travels to each. @@ -55,7 +57,8 @@ terms, which are outside the control of this project. Because the tool has no telemetry channel, there is no separate telemetry stream to opt out of. To omit install-source labels from existing API requests, remove the `--install-source`, `--install-content`, and `--install-campaign` arguments from the MCP -client configuration or add `--no-install-attribution`. To further limit outbound calls, +client configuration. To omit both install-source and agent-host labels, add +`--no-install-attribution`. To further limit outbound calls, run with `--read-only` (no mutating operations) or `--tools` (restrict the exposed tool set, including the optional Microsoft Learn documentation lookup). See [docs/DATA-FLOW.md](docs/DATA-FLOW.md) and diff --git a/README.md b/README.md index 9d0ab4b..a02a495 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,13 @@ 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 @@ -225,7 +232,7 @@ The server accepts configuration via CLI flags or environment variables: | `--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 labels from outbound request metadata | +| `--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 | > The CLI flag wins when both a flag and its env var are set. Run @@ -327,7 +334,7 @@ Every command has built-in help — run `spe-mcp --help` (e.g. | `--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 labels even when they are present in the client configuration or environment. | +| `--no-install-attribution` | Omit install-source and agent-host labels from outbound request metadata. | ## Authentication @@ -647,9 +654,10 @@ tenant/subscription. 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, and campaign labels to that request header. The labels -contain no personal or tenant identifiers, but Microsoft services can associate them -with the authenticated request in normal service logs. Omit them with +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 them with `--no-install-attribution`. 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 diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md index 9d86dee..3d1dd16 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -51,6 +51,8 @@ These never leave your machine: 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, but Microsoft services can associate them with the -authenticated request in normal service logs. Users can omit them with +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 all attribution labels with `--no-install-attribution`. See [PRIVACY.md](../PRIVACY.md) for details. diff --git a/src/cli.ts b/src/cli.ts index 6e127dd..75f782f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -135,7 +135,14 @@ program // 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, installAttribution }); + 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 3c5d3fc..8f70daf 100644 --- a/src/graph-client.test.ts +++ b/src/graph-client.test.ts @@ -28,6 +28,7 @@ import { import { __testing as userAgentTesting, resolveInstallAttribution, + setAgentHostAttribution, setInstallAttribution, } from "./user-agent.js"; @@ -142,12 +143,13 @@ describe("request attribution", () => { 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-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/, ); }); }); diff --git a/src/index.ts b/src/index.ts index 2a38faf..050788f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,8 @@ import { byoAppStartupNote, azLoginNotSignedInMessage } from "./onboarding-messa import { readState } from "./state.js"; import { configureAzureUserAgentEnvironment, + resolveAgentHostAttribution, + setAgentHostAttribution, setInstallAttribution, } from "./user-agent.js"; import { PACKAGE_VERSION } from "./version.js"; @@ -244,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() { @@ -277,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 }); @@ -394,7 +413,10 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => { export async function startServer(config: ServerConfig) { log("Starting SharePoint Embedded MCP Server..."); - setInstallAttribution(config.installAttribution); + 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 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/types.ts b/src/types.ts index bb2ff3a..a58d36f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -107,6 +107,8 @@ export interface ServerConfig { 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 8542cd9..0d6dea3 100644 --- a/src/user-agent.test.ts +++ b/src/user-agent.test.ts @@ -6,9 +6,12 @@ import { USER_AGENT, __testing, appendUserAgent, + classifyAgentHost, configureAzureUserAgentEnvironment, getUserAgent, + resolveAgentHostAttribution, resolveInstallAttribution, + setAgentHostAttribution, setInstallAttribution, } from "./user-agent.js"; @@ -28,6 +31,7 @@ describe("install attribution User-Agent", () => { content: "sharepoint-embedded-mcp-server", campaign: "docs-install-buttons", }); + setInstallAttribution(attribution); expect(getUserAgent()).toBe( @@ -37,6 +41,41 @@ describe("install attribution User-Agent", () => { ); }); + 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({ @@ -96,6 +135,7 @@ describe("install attribution User-Agent", () => { 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", @@ -104,10 +144,10 @@ describe("install attribution User-Agent", () => { configureAzureUserAgentEnvironment(env); expect(env.AZURE_HTTP_USER_AGENT).toMatch( - /^existing-az\/1\.0 spe-mcp-server\/\S+ spe-install-source\/github-readme/, + /^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/, + /^existing-azd\/1\.0 spe-mcp-server\/\S+ spe-install-source\/github-readme.*spe-agent-host\/vscode/, ); }); }); diff --git a/src/user-agent.ts b/src/user-agent.ts index f76ade7..9279397 100644 --- a/src/user-agent.ts +++ b/src/user-agent.ts @@ -36,6 +36,21 @@ export const INSTALL_CONTENTS = [ 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]; @@ -51,6 +66,7 @@ export interface InstallAttributionInput { 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(); @@ -118,17 +134,71 @@ export function setInstallAttribution(attribution: InstallAttribution | undefine activeAttribution = attribution; } -export function getUserAgent(): string { - if (!activeAttribution) return USER_AGENT; +/** + * 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; +} - const tokens = [`spe-install-source/${activeAttribution.source}`]; - if (activeAttribution.content) { - tokens.push(`spe-install-content/${activeAttribution.content}`); +export function getUserAgent(): string { + 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 (activeAttribution.campaign) { - tokens.push(`spe-install-campaign/${activeAttribution.campaign}`); + if (activeAgentHost) { + tokens.push(`spe-agent-host/${activeAgentHost}`); } - return `${USER_AGENT} ${tokens.join(" ")}`; + return tokens.length > 0 ? `${USER_AGENT} ${tokens.join(" ")}` : USER_AGENT; } export function appendUserAgent(existing: string | undefined, value: string): string { @@ -138,7 +208,8 @@ export function appendUserAgent(existing: string | undefined, value: string): st !token.startsWith("spe-mcp-server/") && !token.startsWith("spe-install-source/") && !token.startsWith("spe-install-content/") && - !token.startsWith("spe-install-campaign/"), + !token.startsWith("spe-install-campaign/") && + !token.startsWith("spe-agent-host/"), ); return [...preserved, value].join(" "); } @@ -160,5 +231,6 @@ export function configureAzureUserAgentEnvironment( export const __testing = { reset(): void { activeAttribution = undefined; + activeAgentHost = undefined; }, }; From 9288711102df91a20ca5ea1b16f8eac80aa7474a Mon Sep 17 00:00:00 2001 From: grjoseph Date: Tue, 11 Aug 2026 13:44:08 -0700 Subject: [PATCH 4/4] docs: use managed aka.ms install links Replace long editor-specific README URLs with validated, team-owned aka.ms aliases while retaining surface attribution in their managed destinations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a02a495..bac8c18 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,9 @@ global install is required. ### One-click install -[Install in Visual Studio Code](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22%3A%22spe%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40microsoft%2Fspe-mcp%22%2C%22start%22%2C%22--install-source%22%2C%22github-readme%22%2C%22--install-content%22%2C%22readme-install%22%2C%22--install-campaign%22%2C%22docs-install-buttons%22%5D%7D) +[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://vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22name%22%3A%22spe%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40microsoft%2Fspe-mcp%22%2C%22start%22%2C%22--install-source%22%2C%22github-readme%22%2C%22--install-content%22%2C%22readme-install%22%2C%22--install-campaign%22%2C%22docs-install-buttons%22%5D%7D), [Visual Studio](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22spe%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40microsoft%2Fspe-mcp%22%2C%22start%22%2C%22--install-source%22%2C%22github-readme%22%2C%22--install-content%22%2C%22readme-install%22%2C%22--install-campaign%22%2C%22docs-install-buttons%22%5D%7D), and [Cursor](https://cursor.com/install-mcp?name=spe&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBtaWNyb3NvZnQvc3BlLW1jcCIsInN0YXJ0IiwiLS1pbnN0YWxsLXNvdXJjZSIsImdpdGh1Yi1yZWFkbWUiLCItLWluc3RhbGwtY29udGVudCIsInJlYWRtZS1pbnN0YWxsIiwiLS1pbnN0YWxsLWNhbXBhaWduIiwiZG9jcy1pbnN0YWxsLWJ1dHRvbnMiXX0). 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. +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