touchstone-mcp-tools 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,8 @@ export class WolvesHandler {
22
22
  }
23
23
  // Tool 3: list_assignment_units
24
24
  async listAssignmentUnits(params) {
25
- const result = await this.api.listAssignmentUnits(params.filter);
25
+ const filter = params.filter ?? "IsArchived eq false";
26
+ const result = await this.api.listAssignmentUnits(filter);
26
27
  return {
27
28
  data: result,
28
29
  message: `Found ${result.value.length} assignment unit(s)`,
@@ -79,7 +79,22 @@ function wrapSuccess(result) {
79
79
  };
80
80
  }
81
81
  function wrapError(error) {
82
- const message = error instanceof Error ? error.message : String(error);
82
+ let message;
83
+ let statusCode;
84
+ let detail;
85
+ if (error && typeof error === "object" && "response" in error) {
86
+ // Axios error — extract API response details
87
+ const axiosError = error;
88
+ statusCode = axiosError.response?.status;
89
+ detail = axiosError.response?.data;
90
+ const apiDetail = detail?.detail || detail?.message || detail?.error || detail?.title;
91
+ message = apiDetail
92
+ ? `API error ${statusCode}: ${typeof apiDetail === "string" ? apiDetail : JSON.stringify(apiDetail)}`
93
+ : `Request failed with status code ${statusCode}`;
94
+ }
95
+ else {
96
+ message = error instanceof Error ? error.message : String(error);
97
+ }
83
98
  return {
84
99
  content: [
85
100
  {
@@ -87,6 +102,8 @@ function wrapError(error) {
87
102
  text: JSON.stringify({
88
103
  success: false,
89
104
  error: message,
105
+ ...(statusCode ? { statusCode } : {}),
106
+ ...(detail ? { detail } : {}),
90
107
  }, null, 2),
91
108
  },
92
109
  ],
@@ -1,9 +1,13 @@
1
1
  export declare class TokenManager {
2
- private credential;
3
- private cachedToken;
4
- private static readonly SCOPE;
5
- private static readonly BUFFER_MS;
2
+ private cachedTokens;
3
+ private tokenFilePath;
6
4
  constructor();
7
5
  getToken(): Promise<string>;
8
- private isTokenValid;
6
+ private isAccessTokenValid;
7
+ private refreshAccessToken;
8
+ private interactiveLogin;
9
+ private waitForAuthCode;
10
+ private saveTokens;
11
+ private loadCachedTokens;
12
+ private openBrowser;
9
13
  }
@@ -1,32 +1,189 @@
1
- import { DefaultAzureCredential } from "@azure/identity";
1
+ import { createServer } from "http";
2
+ import { randomBytes, createHash } from "crypto";
3
+ import { exec } from "child_process";
4
+ import { URL, URLSearchParams } from "url";
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
6
+ import { join } from "path";
7
+ import { tmpdir, platform } from "os";
8
+ // Mars app — the only app ID trusted by ExP API
9
+ const MARS_CLIENT_ID = "4728d04c-aaa7-4d80-9a24-f6f7d45c1be3";
10
+ const MICROSOFT_TENANT_ID = "72f988bf-86f1-41af-91ab-2d7cd011db47";
11
+ const TOKEN_ENDPOINT = `https://login.microsoftonline.com/${MICROSOFT_TENANT_ID}/oauth2/v2.0/token`;
12
+ const AUTHORIZE_ENDPOINT = `https://login.microsoftonline.com/${MICROSOFT_TENANT_ID}/oauth2/v2.0/authorize`;
13
+ const REDIRECT_URI = "http://localhost:4000/auth";
14
+ const SCOPE = "openid profile User.Read";
15
+ // Refresh token 5 minutes before access token expiry
16
+ const BUFFER_MS = 5 * 60 * 1000;
17
+ const LOGIN_TIMEOUT_MS = 120_000;
2
18
  export class TokenManager {
3
- credential;
4
- cachedToken = null;
5
- // TouchStone API validates against Microsoft Graph
6
- static SCOPE = "https://graph.microsoft.com/.default";
7
- // Refresh token 5 minutes before expiry
8
- static BUFFER_MS = 5 * 60 * 1000;
19
+ cachedTokens = null;
20
+ tokenFilePath;
9
21
  constructor() {
10
- this.credential = new DefaultAzureCredential();
22
+ const cacheDir = join(tmpdir(), "touchstone-mcp");
23
+ if (!existsSync(cacheDir)) {
24
+ mkdirSync(cacheDir, { recursive: true });
25
+ }
26
+ this.tokenFilePath = join(cacheDir, "token-cache.json");
27
+ this.loadCachedTokens();
11
28
  }
12
29
  async getToken() {
13
- if (this.isTokenValid()) {
14
- return this.cachedToken.token;
30
+ // 1. Check cached access token
31
+ if (this.isAccessTokenValid()) {
32
+ return this.cachedTokens.access_token;
33
+ }
34
+ // 2. Try refresh token
35
+ if (this.cachedTokens?.refresh_token) {
36
+ try {
37
+ await this.refreshAccessToken();
38
+ return this.cachedTokens.access_token;
39
+ }
40
+ catch {
41
+ // Refresh failed (expired or revoked), fall through to interactive login
42
+ console.error("Refresh token expired, interactive login required.");
43
+ }
44
+ }
45
+ // 3. Interactive browser login
46
+ await this.interactiveLogin();
47
+ return this.cachedTokens.access_token;
48
+ }
49
+ // ---- Private: Token lifecycle ----
50
+ isAccessTokenValid() {
51
+ if (!this.cachedTokens?.access_token)
52
+ return false;
53
+ return Date.now() < this.cachedTokens.expires_at - BUFFER_MS;
54
+ }
55
+ async refreshAccessToken() {
56
+ const resp = await fetch(TOKEN_ENDPOINT, {
57
+ method: "POST",
58
+ headers: {
59
+ "Content-Type": "application/x-www-form-urlencoded",
60
+ "Origin": REDIRECT_URI.replace("/auth", ""),
61
+ },
62
+ body: new URLSearchParams({
63
+ client_id: MARS_CLIENT_ID,
64
+ scope: SCOPE,
65
+ refresh_token: this.cachedTokens.refresh_token,
66
+ grant_type: "refresh_token",
67
+ }),
68
+ });
69
+ const data = await resp.json();
70
+ if (!data.access_token) {
71
+ throw new Error(data.error_description || "Token refresh failed");
72
+ }
73
+ this.saveTokens(data);
74
+ }
75
+ async interactiveLogin() {
76
+ // PKCE
77
+ const codeVerifier = randomBytes(32).toString("base64url");
78
+ const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
79
+ const authParams = new URLSearchParams({
80
+ client_id: MARS_CLIENT_ID,
81
+ response_type: "code",
82
+ redirect_uri: REDIRECT_URI,
83
+ scope: SCOPE,
84
+ response_mode: "query",
85
+ prompt: "select_account",
86
+ code_challenge: codeChallenge,
87
+ code_challenge_method: "S256",
88
+ });
89
+ const authUrl = `${AUTHORIZE_ENDPOINT}?${authParams}`;
90
+ // Wait for auth code via local HTTP server
91
+ const code = await this.waitForAuthCode(authUrl);
92
+ // Exchange code for tokens
93
+ const resp = await fetch(TOKEN_ENDPOINT, {
94
+ method: "POST",
95
+ headers: {
96
+ "Content-Type": "application/x-www-form-urlencoded",
97
+ "Origin": REDIRECT_URI.replace("/auth", ""),
98
+ },
99
+ body: new URLSearchParams({
100
+ client_id: MARS_CLIENT_ID,
101
+ scope: SCOPE,
102
+ code,
103
+ redirect_uri: REDIRECT_URI,
104
+ grant_type: "authorization_code",
105
+ code_verifier: codeVerifier,
106
+ }),
107
+ });
108
+ const data = await resp.json();
109
+ if (!data.access_token) {
110
+ throw new Error(`Interactive login failed: ${data.error_description || JSON.stringify(data)}`);
15
111
  }
112
+ this.saveTokens(data);
113
+ console.error("TouchStone login successful.");
114
+ }
115
+ waitForAuthCode(authUrl) {
116
+ return new Promise((resolve, reject) => {
117
+ const server = createServer((req, res) => {
118
+ const url = new URL(req.url || "/", "http://localhost:4000");
119
+ const authCode = url.searchParams.get("code");
120
+ const error = url.searchParams.get("error_description");
121
+ if (authCode) {
122
+ res.writeHead(200, { "Content-Type": "text/html" });
123
+ res.end("Login successful. You can close this window.");
124
+ server.close();
125
+ resolve(authCode);
126
+ }
127
+ else if (error) {
128
+ res.writeHead(400, { "Content-Type": "text/html" });
129
+ res.end(`Login failed: ${error}`);
130
+ server.close();
131
+ reject(new Error(error));
132
+ }
133
+ else {
134
+ res.writeHead(200);
135
+ res.end("");
136
+ }
137
+ });
138
+ server.listen(4000, () => {
139
+ console.error("Opening browser for TouchStone login...");
140
+ console.error("If browser does not open, visit: " + authUrl);
141
+ this.openBrowser(authUrl);
142
+ });
143
+ setTimeout(() => {
144
+ server.close();
145
+ reject(new Error("Login timed out after 2 minutes. Please try again."));
146
+ }, LOGIN_TIMEOUT_MS);
147
+ });
148
+ }
149
+ // ---- Private: Token persistence ----
150
+ saveTokens(data) {
151
+ this.cachedTokens = {
152
+ access_token: data.access_token,
153
+ refresh_token: data.refresh_token,
154
+ expires_at: Date.now() + (data.expires_in || 3600) * 1000,
155
+ };
16
156
  try {
17
- this.cachedToken = await this.credential.getToken(TokenManager.SCOPE);
18
- return this.cachedToken.token;
157
+ writeFileSync(this.tokenFilePath, JSON.stringify(this.cachedTokens), { mode: 0o600 });
19
158
  }
20
- catch (error) {
21
- throw new Error("Azure authentication failed. Please ensure you are logged in by running `az login`. " +
22
- `Details: ${error instanceof Error ? error.message : String(error)}`);
159
+ catch {
160
+ // Non-fatal: tokens still work in memory
23
161
  }
24
162
  }
25
- isTokenValid() {
26
- if (!this.cachedToken)
27
- return false;
28
- const now = Date.now();
29
- const expiresAt = this.cachedToken.expiresOnTimestamp - TokenManager.BUFFER_MS;
30
- return now < expiresAt;
163
+ loadCachedTokens() {
164
+ try {
165
+ if (existsSync(this.tokenFilePath)) {
166
+ const data = JSON.parse(readFileSync(this.tokenFilePath, "utf8"));
167
+ if (data.access_token && data.refresh_token) {
168
+ this.cachedTokens = data;
169
+ }
170
+ }
171
+ }
172
+ catch {
173
+ // Corrupted cache file, ignore
174
+ }
175
+ }
176
+ // ---- Private: Cross-platform browser open ----
177
+ openBrowser(url) {
178
+ const os = platform();
179
+ const cmd = os === "win32"
180
+ ? `cmd.exe /c start "" "${url}"`
181
+ : os === "darwin"
182
+ ? `open "${url}"`
183
+ : `xdg-open "${url}"`;
184
+ exec(cmd, (err) => {
185
+ if (err)
186
+ console.error("Could not open browser automatically.");
187
+ });
31
188
  }
32
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "touchstone-mcp-tools",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "MCP tools for TouchStone Experimentation platform",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -20,7 +20,6 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.26.0",
23
- "@azure/identity": "^4.6.0",
24
23
  "axios": "^1.7.0"
25
24
  },
26
25
  "devDependencies": {
package/README.md DELETED
@@ -1,199 +0,0 @@
1
- # wolves-mcp-tools
2
-
3
- MCP (Model Context Protocol) server for the [Wolves Experimentation](https://wolves.microsoft.com) platform. Enables AI coding agents (Claude Code, Copilot CLI, Codex CLI) to create and manage A/B experiments through natural language interaction.
4
-
5
- ## Prerequisites
6
-
7
- - **Node.js** >= 18.0.0
8
- - **Azure CLI** — You must be logged in via `az login` for authentication. The server uses `DefaultAzureCredential` to acquire tokens scoped to `https://graph.microsoft.com/.default`.
9
-
10
- ## Installation
11
-
12
- ```bash
13
- # Install from npm
14
- npm install wolves-mcp-tools
15
-
16
- # Or run directly via npx (no install needed)
17
- npx -y wolves-mcp-tools
18
- ```
19
-
20
- ## MCP Configuration
21
-
22
- Add the following to your `.mcp.json` to register the server with your AI coding agent:
23
-
24
- ```json
25
- {
26
- "mcpServers": {
27
- "wolves-tools": {
28
- "type": "stdio",
29
- "command": "npx",
30
- "args": ["-y", "wolves-mcp-tools"],
31
- "description": "Wolves MCP tools for experiment management"
32
- }
33
- }
34
- }
35
- ```
36
-
37
- ## Available Tools
38
-
39
- | Tool | Description |
40
- |------|-------------|
41
- | `create_experiment` | Create a new A/B experiment in Wolves. Checks for duplicate names before creation. |
42
- | `update_experiment` | Update an existing experiment. Fetches current state and merges changes to prevent data loss. |
43
- | `list_experiments` | List all experiments with optional client-side filtering by status and result limiting. |
44
- | `get_experiment` | Get detailed information about a specific experiment by its UUID. |
45
- | `list_metrics` | List available metrics, optionally filtered by type (`count`, `sum`, `ratio`, `conversion`) or status. |
46
- | `list_api_keys` | List API keys, optionally filtered by experiment ID. |
47
- | `update_api_keys` | Update an API key's name, active status, or experiment/gate permissions. |
48
-
49
- ### create_experiment
50
-
51
- Creates a new A/B experiment with the specified groups and configuration.
52
-
53
- **Parameters:**
54
-
55
- | Parameter | Type | Required | Default | Description |
56
- |-----------|------|----------|---------|-------------|
57
- | `name` | string | Yes | — | Experiment name |
58
- | `groups` | array | Yes | — | Experiment groups (variants). First group defaults to control. |
59
- | `hypothesis` | string | No | — | Experiment hypothesis |
60
- | `allocationPercentage` | number | No | 100 | Overall traffic allocation (0-100) |
61
- | `targetDurationDays` | number | No | 14 | Target experiment duration in days |
62
- | `type` | string | No | `"ab"` | Experiment type |
63
- | `idType` | string | No | `"session_id"` | ID type for assignment |
64
-
65
- Each group requires `name` (string) and `size` (number, 0-100). Optional fields: `isControl` (boolean), `parameters` (array of `{name, type, value}`).
66
-
67
- ### update_experiment
68
-
69
- Updates an existing experiment's configuration. Only provided fields are updated.
70
-
71
- **Parameters:**
72
-
73
- | Parameter | Type | Required | Description |
74
- |-----------|------|----------|-------------|
75
- | `experiment_id` | string (UUID) | Yes | The experiment to update |
76
- | `name` | string | No | New experiment name |
77
- | `hypothesis` | string | No | New hypothesis |
78
- | `allocationPercentage` | number | No | New traffic allocation (0-100) |
79
- | `groups` | array | No | Updated groups (replaces all existing) |
80
- | `targetDurationDays` | number | No | New target duration in days |
81
- | `type` | string | No | Experiment type |
82
- | `idType` | string | No | ID type for assignment |
83
- | `targetingCriteria` | string | No | Targeting criteria expression |
84
- | `analysisType` | string | No | Analysis type |
85
- | `defaultConfidenceInterval` | number | No | Default confidence interval |
86
-
87
- ### list_experiments
88
-
89
- Lists all experiments with optional filtering.
90
-
91
- | Parameter | Type | Required | Description |
92
- |-----------|------|----------|-------------|
93
- | `status` | string | No | Filter by: `"setup"`, `"in_progress"`, or `"completed"` |
94
- | `limit` | number | No | Maximum number of results to return |
95
-
96
- ### get_experiment
97
-
98
- | Parameter | Type | Required | Description |
99
- |-----------|------|----------|-------------|
100
- | `experiment_id` | string (UUID) | Yes | The experiment to retrieve |
101
-
102
- ### list_metrics
103
-
104
- | Parameter | Type | Required | Description |
105
- |-----------|------|----------|-------------|
106
- | `type` | string | No | Filter by: `"count"`, `"sum"`, `"ratio"`, or `"conversion"` |
107
- | `status` | string | No | Filter by metric status |
108
-
109
- ### list_api_keys
110
-
111
- | Parameter | Type | Required | Description |
112
- |-----------|------|----------|-------------|
113
- | `experiment_id` | string | No | Filter keys by experiment ID |
114
-
115
- ### update_api_keys
116
-
117
- | Parameter | Type | Required | Description |
118
- |-----------|------|----------|-------------|
119
- | `key_id` | string (UUID) | Yes | The API key to update |
120
- | `name` | string | No | New name for the API key |
121
- | `is_active` | boolean | No | Whether the key is active |
122
- | `experiment_ids` | string[] | No | Experiment IDs this key has access to (replaces existing) |
123
- | `gate_ids` | string[] | No | Gate IDs this key has access to (replaces existing) |
124
-
125
- ## Development
126
-
127
- ### Setup
128
-
129
- ```bash
130
- cd mcp-tools
131
- npm install
132
- ```
133
-
134
- ### Build
135
-
136
- ```bash
137
- npm run build
138
- ```
139
-
140
- This compiles TypeScript from `src/` to `build/` using `tsc`.
141
-
142
- ### Run
143
-
144
- ```bash
145
- # Run compiled build
146
- npm start
147
-
148
- # Run in development mode (ts-node, no build needed)
149
- npm run dev
150
- ```
151
-
152
- ### Test
153
-
154
- ```bash
155
- # Run all tests
156
- npm test
157
-
158
- # Run tests with coverage report
159
- npm run test:coverage
160
- ```
161
-
162
- ### Publish to npm
163
-
164
- The package is configured for publishing to the public npm registry.
165
-
166
- ```bash
167
- # 1. Login to npm (one-time)
168
- npm login
169
-
170
- # 2. Publish (automatically runs build via prepublishOnly)
171
- npm publish
172
- ```
173
-
174
- The `files` field in `package.json` ensures only the `build/` directory is included in the published package. No source code, tests, or documentation are shipped.
175
-
176
- ## Architecture
177
-
178
- ```
179
- mcp-tools/src/
180
- +-- index.ts # Barrel exports
181
- +-- servers/
182
- | +-- wolves-server.ts # MCP server wiring + CLI entry point
183
- +-- handlers/
184
- | +-- wolves-handler.ts # Business logic (7 tool methods)
185
- +-- utils/
186
- | +-- token-manager.ts # DefaultAzureCredential + token caching
187
- | +-- wolves-api.ts # Axios HTTP client for Wolves REST API
188
- +-- schemas/
189
- +-- wolves-tools.json # MCP tool definitions (JSON Schema)
190
- ```
191
-
192
- - **Server** — Registers MCP tools and routes calls to handler methods via stdio transport.
193
- - **Handler** — Implements business logic including duplicate name checking, fetch-then-merge updates, and client-side filtering.
194
- - **API Client** — Axios-based HTTP client with automatic Bearer token injection.
195
- - **Token Manager** — Acquires and caches Azure AD tokens using `DefaultAzureCredential`, auto-refreshing 5 minutes before expiry.
196
-
197
- ## License
198
-
199
- MIT