diff --git a/CHANGELOG.md b/CHANGELOG.md index ec93124..0570dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,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/PRIVACY.md b/PRIVACY.md index d3624c6..a1d080b 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 telemetry or usage analytics, and it opens no dedicated 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 @@ -24,11 +23,15 @@ 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 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. @@ -51,7 +54,12 @@ 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. 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 +[docs/SECURITY-CONTROLS.md](docs/SECURITY-CONTROLS.md). diff --git a/README.md b/README.md index eb3bc87..bac8c18 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,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 @@ -82,7 +100,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 +128,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 +229,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 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 @@ -271,7 +313,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 +331,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 @@ -335,12 +381,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 +652,14 @@ 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**. 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 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 [Microsoft Privacy Statement](https://privacy.microsoft.com/privacystatement). diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md index 8dbb395..3d1dd16 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -48,6 +48,11 @@ 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 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 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 0e3e73d..75f782f 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,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 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..050788f 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 { USER_AGENT } 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,12 +433,9 @@ 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; - } + // 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/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 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/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 new file mode 100644 index 0000000..0d6dea3 --- /dev/null +++ b/src/user-agent.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { afterEach, describe, expect, it } from "vitest"; +import { + USER_AGENT, + __testing, + appendUserAgent, + classifyAgentHost, + configureAzureUserAgentEnvironment, + getUserAgent, + resolveAgentHostAttribution, + resolveInstallAttribution, + setAgentHostAttribution, + 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.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/, + ); + }); +}); diff --git a/src/user-agent.ts b/src/user-agent.ts index 29ba213..9279397 100644 --- a/src/user-agent.ts +++ b/src/user-agent.ts @@ -2,18 +2,235 @@ // 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 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; +} + +/** + * 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 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 (activeAgentHost) { + tokens.push(`spe-agent-host/${activeAgentHost}`); + } + return tokens.length > 0 ? `${USER_AGENT} ${tokens.join(" ")}` : USER_AGENT; +} + +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/") && + !token.startsWith("spe-agent-host/"), + ); + 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; + activeAgentHost = 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); }); });