From 3ec41a606f31dc0b5a39da9b6d2defe35e002d67 Mon Sep 17 00:00:00 2001 From: Yuki Fujisaki Date: Fri, 15 May 2026 10:59:38 +0900 Subject: [PATCH] feat: send User-Agent header on DeployGate API requests Identify the plugin (and its version) on every request so DeployGate can attribute and triage traffic from this MCP integration. Without this, requests landed under undici's default user agent. The plugin version is sourced from package.json at bundle time via an esbuild --define injection, so there is a single source of truth and the User-Agent (and the McpServer version) stay in sync with releases automatically. The dev path (`npm start` running unbundled dist/) falls back to "dev". Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- scripts/build-bundle.mjs | 19 +++++++++++++++++++ src/__tests__/client.test.ts | 23 +++++++++++++++++++++++ src/client.ts | 8 +++++++- src/index.ts | 3 ++- src/version.ts | 4 ++++ 6 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 scripts/build-bundle.mjs create mode 100644 src/version.ts diff --git a/package.json b/package.json index a90ccc4..cfffa4e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "build": "tsc", - "bundle": "tsc && esbuild dist/index.js --bundle --platform=node --format=esm --outfile=plugin/scripts/bundle.js", + "bundle": "tsc && node scripts/build-bundle.mjs", "prepare": "tsc && (git config core.hooksPath .githooks || true)", "dev": "tsc --watch", "start": "node dist/index.js", diff --git a/scripts/build-bundle.mjs b/scripts/build-bundle.mjs new file mode 100644 index 0000000..23495b6 --- /dev/null +++ b/scripts/build-bundle.mjs @@ -0,0 +1,19 @@ +import { build } from "esbuild"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); +const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf-8")); + +await build({ + entryPoints: [resolve(root, "dist/index.js")], + bundle: true, + platform: "node", + format: "esm", + outfile: resolve(root, "plugin/scripts/bundle.js"), + define: { + __PLUGIN_VERSION__: JSON.stringify(pkg.version), + }, +}); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 0455bcf..d9eefba 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -75,6 +75,18 @@ describe("DeployGateClient", () => { expect(options.headers.Authorization).toBe("Bearer test-token"); }); + it("sends User-Agent header identifying the plugin", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ error: false, results: [] }), + ); + await client.getOrganizations(); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.headers["User-Agent"]).toMatch( + /^deploygate-agent-plugin\/(\d+\.\d+\.\d+|dev)$/, + ); + }); + it("throws DeployGateApiError on error response", async () => { mockFetch.mockResolvedValueOnce( mockResponse({ @@ -143,6 +155,17 @@ describe("DeployGateClient", () => { expect(options.headers.Authorization).toBeUndefined(); }); + it("sends User-Agent header even on unauthenticated requests", async () => { + mockFetch.mockResolvedValueOnce( + mockResponse({ error: false, results: {} }), + ); + await client.requestRaw("GET", "/api/x", { authenticated: false }); + const [, options] = mockFetch.mock.calls[0]; + expect(options.headers["User-Agent"]).toMatch( + /^deploygate-agent-plugin\/(\d+\.\d+\.\d+|dev)$/, + ); + }); + it("sends extra headers", async () => { mockFetch.mockResolvedValueOnce( mockResponse({ error: false, results: {} }), diff --git a/src/client.ts b/src/client.ts index 9833488..e667c5e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,9 @@ import { readFile } from "node:fs/promises"; import { basename } from "node:path"; +import { VERSION } from "./version.js"; const BASE_URL = "https://deploygate.com"; +const USER_AGENT = `deploygate-agent-plugin/${VERSION}`; export interface DeployGateErrorDetail { error: true; @@ -50,7 +52,10 @@ export class DeployGateClient { }, ): Promise<{ status: number; data: unknown }> { const url = `${BASE_URL}${path}`; - const headers: Record = { ...(options.headers ?? {}) }; + const headers: Record = { + "User-Agent": USER_AGENT, + ...(options.headers ?? {}), + }; if (options.authenticated) { if (!this.token) { @@ -90,6 +95,7 @@ export class DeployGateClient { } const url = `${BASE_URL}${path}`; const headers: Record = { + "User-Agent": USER_AGENT, Authorization: `Bearer ${this.token}`, }; diff --git a/src/index.ts b/src/index.ts index a8e9c69..9dd611d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { DeployGateClient } from "./client.js"; import { TokenStore } from "./token-store.js"; +import { VERSION } from "./version.js"; import { registerAuthTools } from "./tools/auth.js"; import { registerUploadTools } from "./tools/upload.js"; import { registerDistributionTools } from "./tools/distributions.js"; @@ -18,7 +19,7 @@ const client = new DeployGateClient(stored?.token); const server = new McpServer({ name: "deploygate", - version: "1.3.0", + version: VERSION, }); registerAuthTools(server, client, tokenStore); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..6529bcd --- /dev/null +++ b/src/version.ts @@ -0,0 +1,4 @@ +declare const __PLUGIN_VERSION__: string | undefined; + +export const VERSION = + typeof __PLUGIN_VERSION__ !== "undefined" ? __PLUGIN_VERSION__ : "dev";