touchstone-mcp-tools 1.0.0

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.
package/README.md ADDED
@@ -0,0 +1,199 @@
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
@@ -0,0 +1,55 @@
1
+ import { WolvesApiClient } from "../utils/wolves-api.js";
2
+ export interface ToolResult {
3
+ data: any;
4
+ message: string;
5
+ }
6
+ export declare class WolvesHandler {
7
+ private api;
8
+ constructor(api?: WolvesApiClient);
9
+ listExperimentGroups(): Promise<ToolResult>;
10
+ listProgressionTemplates(params: {
11
+ group_id: string;
12
+ }): Promise<ToolResult>;
13
+ listAssignmentUnits(params: {
14
+ filter?: string;
15
+ }): Promise<ToolResult>;
16
+ searchSubscribers(params: {
17
+ search: string;
18
+ count?: number;
19
+ fetch_user_photo?: boolean;
20
+ }): Promise<ToolResult>;
21
+ createExperiment(params: {
22
+ name: string;
23
+ experimentation_group: string;
24
+ variants: Array<{
25
+ id?: string;
26
+ type: "control" | "treatment";
27
+ description?: string;
28
+ }>;
29
+ description?: string;
30
+ assignment_unit_id?: string;
31
+ subscribers?: string[];
32
+ metrics?: Array<{
33
+ name: string;
34
+ type: string;
35
+ description?: string;
36
+ }>;
37
+ progressions?: Array<{
38
+ name: string;
39
+ feature_gates?: any[];
40
+ stages?: any[];
41
+ progression_template_id?: string;
42
+ progression_template_parameters?: Record<string, any>;
43
+ progression_parameters?: any[];
44
+ }>;
45
+ }): Promise<ToolResult>;
46
+ getExperiment(params: {
47
+ experiment_id: string;
48
+ }): Promise<ToolResult>;
49
+ listExperiments(params: {
50
+ page?: number;
51
+ page_size?: number;
52
+ status?: string;
53
+ search?: string;
54
+ }): Promise<ToolResult>;
55
+ }
@@ -0,0 +1,77 @@
1
+ import { WolvesApiClient } from "../utils/wolves-api.js";
2
+ export class WolvesHandler {
3
+ api;
4
+ constructor(api) {
5
+ this.api = api || new WolvesApiClient();
6
+ }
7
+ // Tool 1: list_experiment_groups
8
+ async listExperimentGroups() {
9
+ const result = await this.api.listExperimentGroups();
10
+ return {
11
+ data: result,
12
+ message: `Found ${result.length} experiment group(s)`,
13
+ };
14
+ }
15
+ // Tool 2: list_progression_templates
16
+ async listProgressionTemplates(params) {
17
+ const result = await this.api.listProgressionTemplates(params.group_id);
18
+ return {
19
+ data: result,
20
+ message: `Found ${result.length} progression template(s) for group "${params.group_id}"`,
21
+ };
22
+ }
23
+ // Tool 3: list_assignment_units
24
+ async listAssignmentUnits(params) {
25
+ const result = await this.api.listAssignmentUnits(params.filter);
26
+ return {
27
+ data: result,
28
+ message: `Found ${result.value.length} assignment unit(s)`,
29
+ };
30
+ }
31
+ // Tool 4: search_subscribers
32
+ async searchSubscribers(params) {
33
+ const result = await this.api.searchSubscribers(params.search, params.count, params.fetch_user_photo);
34
+ return {
35
+ data: result,
36
+ message: `Found ${result.length} subscriber(s) matching "${params.search}"`,
37
+ };
38
+ }
39
+ // Tool 5: create_experiment
40
+ async createExperiment(params) {
41
+ const result = await this.api.createExperiment({
42
+ name: params.name,
43
+ description: params.description,
44
+ experimentation_group: params.experimentation_group,
45
+ assignment_unit_id: params.assignment_unit_id,
46
+ subscribers: params.subscribers,
47
+ variants: params.variants,
48
+ metrics: params.metrics,
49
+ progressions: params.progressions,
50
+ });
51
+ return {
52
+ data: result,
53
+ message: `Experiment "${result.name}" created (ID: ${result.id})`,
54
+ };
55
+ }
56
+ // Tool 6: get_experiment
57
+ async getExperiment(params) {
58
+ const result = await this.api.getExperiment(params.experiment_id);
59
+ return {
60
+ data: result,
61
+ message: `Retrieved experiment "${result.name}" (status: ${result.status})`,
62
+ };
63
+ }
64
+ // Tool 7: list_experiments
65
+ async listExperiments(params) {
66
+ const result = await this.api.listExperiments({
67
+ page: params.page,
68
+ page_size: params.page_size,
69
+ status: params.status,
70
+ search: params.search,
71
+ });
72
+ return {
73
+ data: result,
74
+ message: `Found ${result.items.length} experiment(s) (page ${result.page}/${result.total_pages}, total: ${result.total})`,
75
+ };
76
+ }
77
+ }
@@ -0,0 +1,5 @@
1
+ export { WolvesServer } from "./servers/wolves-server.js";
2
+ export { WolvesHandler, type ToolResult } from "./handlers/wolves-handler.js";
3
+ export { WolvesApiClient } from "./utils/wolves-api.js";
4
+ export { TokenManager } from "./utils/token-manager.js";
5
+ export type { TouchStoneApiConfig, ExperimentGroupResponse, ProgressionTemplateResponse, AssignmentUnitItem, AssignmentUnitsListResponse, SubscriberSearchResult, VariantInput, MetricInput, ProgressionInput, CreateExperimentRequest, ExperimentDetail, ExperimentSummary, PaginatedExperimentsResponse, } from "./utils/wolves-api.js";
package/build/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // Barrel exports
2
+ export { WolvesServer } from "./servers/wolves-server.js";
3
+ export { WolvesHandler } from "./handlers/wolves-handler.js";
4
+ export { WolvesApiClient } from "./utils/wolves-api.js";
5
+ export { TokenManager } from "./utils/token-manager.js";
@@ -0,0 +1,157 @@
1
+ {
2
+ "list_experiment_groups": {
3
+ "name": "list_experiment_groups",
4
+ "description": "List all experimentation groups in TouchStone. Returns group IDs and names for use when creating experiments.",
5
+ "inputSchema": {
6
+ "type": "object",
7
+ "properties": {}
8
+ }
9
+ },
10
+ "list_progression_templates": {
11
+ "name": "list_progression_templates",
12
+ "description": "List progression templates for a specific experimentation group. Returns available templates that can be used when creating experiment progressions.",
13
+ "inputSchema": {
14
+ "type": "object",
15
+ "required": ["group_id"],
16
+ "properties": {
17
+ "group_id": {
18
+ "type": "string",
19
+ "description": "The experimentation group ID (e.g., 'exponexpws~authoring')"
20
+ }
21
+ }
22
+ }
23
+ },
24
+ "list_assignment_units": {
25
+ "name": "list_assignment_units",
26
+ "description": "List assignment units in TouchStone. Assignment units define how users are assigned to experiment variants (e.g., by user ID, session ID).",
27
+ "inputSchema": {
28
+ "type": "object",
29
+ "properties": {
30
+ "filter": {
31
+ "type": "string",
32
+ "description": "OData filter expression (e.g., 'IsArchived eq false')"
33
+ }
34
+ }
35
+ }
36
+ },
37
+ "search_subscribers": {
38
+ "name": "search_subscribers",
39
+ "description": "Search for subscribers by name or email. Subscribers can be added to experiments to receive notifications.",
40
+ "inputSchema": {
41
+ "type": "object",
42
+ "required": ["search"],
43
+ "properties": {
44
+ "search": {
45
+ "type": "string",
46
+ "description": "Search string for subscriber name or email"
47
+ },
48
+ "count": {
49
+ "type": "number",
50
+ "description": "Maximum number of results to return (default: 5)"
51
+ },
52
+ "fetch_user_photo": {
53
+ "type": "boolean",
54
+ "description": "Whether to include user photos in results (default: false)"
55
+ }
56
+ }
57
+ }
58
+ },
59
+ "create_experiment": {
60
+ "name": "create_experiment",
61
+ "description": "Create a new experiment in TouchStone. Requires a name, experimentation group, and at least one variant (control/treatment).",
62
+ "inputSchema": {
63
+ "type": "object",
64
+ "required": ["name", "experimentation_group", "variants"],
65
+ "properties": {
66
+ "name": {
67
+ "type": "string",
68
+ "description": "Name of the experiment"
69
+ },
70
+ "description": {
71
+ "type": "string",
72
+ "description": "Description of the experiment"
73
+ },
74
+ "experimentation_group": {
75
+ "type": "string",
76
+ "description": "Experimentation group ID (e.g., 'exponexpws~test')"
77
+ },
78
+ "assignment_unit_id": {
79
+ "type": "string",
80
+ "description": "Assignment unit ID (e.g., 'userid')"
81
+ },
82
+ "subscribers": {
83
+ "type": "array",
84
+ "items": { "type": "string" },
85
+ "description": "List of subscriber display names to notify"
86
+ },
87
+ "variants": {
88
+ "type": "array",
89
+ "description": "Experiment variants. Must include at least one control and one treatment.",
90
+ "minItems": 1,
91
+ "items": {
92
+ "type": "object",
93
+ "required": ["type"],
94
+ "properties": {
95
+ "id": { "type": "string", "description": "Variant ID (auto-generated if omitted)" },
96
+ "type": { "type": "string", "enum": ["control", "treatment"], "description": "Variant type" },
97
+ "description": { "type": "string", "description": "Variant description" }
98
+ }
99
+ }
100
+ },
101
+ "metrics": {
102
+ "type": "array",
103
+ "description": "Metrics to track for this experiment",
104
+ "items": {
105
+ "type": "object",
106
+ "required": ["name", "type"],
107
+ "properties": {
108
+ "name": { "type": "string", "description": "Metric name" },
109
+ "type": { "type": "string", "description": "Metric type (e.g., 'Primary', 'Guardrail')" },
110
+ "description": { "type": "string", "description": "Metric description" }
111
+ }
112
+ }
113
+ },
114
+ "progressions": {
115
+ "type": "array",
116
+ "description": "Progression configurations for staged rollout",
117
+ "items": {
118
+ "type": "object",
119
+ "required": ["name"],
120
+ "properties": {
121
+ "name": { "type": "string", "description": "Progression name" },
122
+ "feature_gates": { "type": "array", "description": "Feature gate conditions" },
123
+ "stages": { "type": "array", "description": "Rollout stages" },
124
+ "progression_template_id": { "type": "string", "description": "ID of the progression template to use" },
125
+ "progression_template_parameters": { "type": "object", "description": "Template parameters" },
126
+ "progression_parameters": { "type": "array", "description": "Progression parameters" }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+ },
133
+ "get_experiment": {
134
+ "name": "get_experiment",
135
+ "description": "Get detailed information about a specific experiment by its ID.",
136
+ "inputSchema": {
137
+ "type": "object",
138
+ "required": ["experiment_id"],
139
+ "properties": {
140
+ "experiment_id": { "type": "string", "description": "UUID of the experiment" }
141
+ }
142
+ }
143
+ },
144
+ "list_experiments": {
145
+ "name": "list_experiments",
146
+ "description": "List experiments with pagination and optional filtering. Returns experiment summaries with pagination metadata.",
147
+ "inputSchema": {
148
+ "type": "object",
149
+ "properties": {
150
+ "page": { "type": "number", "description": "Page number (default: 1)" },
151
+ "page_size": { "type": "number", "description": "Results per page (default: 20, max: 100)" },
152
+ "status": { "type": "string", "description": "Filter by status: 'new', 'running', or 'stopped'" },
153
+ "search": { "type": "string", "description": "Search in experiment name" }
154
+ }
155
+ }
156
+ }
157
+ }
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { WolvesHandler } from "../handlers/wolves-handler.js";
3
+ export declare class WolvesServer {
4
+ private server;
5
+ private handler;
6
+ constructor(handler?: WolvesHandler);
7
+ private setupToolHandlers;
8
+ private handleToolCall;
9
+ run(): Promise<void>;
10
+ }
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { WolvesHandler } from "../handlers/wolves-handler.js";
6
+ import toolDefinitions from "../schemas/wolves-tools.json" with { type: "json" };
7
+ export class WolvesServer {
8
+ server;
9
+ handler;
10
+ constructor(handler) {
11
+ this.handler = handler || new WolvesHandler();
12
+ this.server = new Server({ name: "touchstone-tools", version: "1.0.0" }, { capabilities: { tools: {} } });
13
+ this.setupToolHandlers();
14
+ }
15
+ setupToolHandlers() {
16
+ // List all available tools
17
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
18
+ tools: Object.values(toolDefinitions),
19
+ }));
20
+ // Route tool calls to handler methods
21
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
22
+ const { name, arguments: args } = request.params;
23
+ return this.handleToolCall(name, args);
24
+ });
25
+ }
26
+ async handleToolCall(name, args) {
27
+ try {
28
+ let result;
29
+ switch (name) {
30
+ case "list_experiment_groups":
31
+ result = await this.handler.listExperimentGroups();
32
+ break;
33
+ case "list_progression_templates":
34
+ result = await this.handler.listProgressionTemplates(args);
35
+ break;
36
+ case "list_assignment_units":
37
+ result = await this.handler.listAssignmentUnits(args);
38
+ break;
39
+ case "search_subscribers":
40
+ result = await this.handler.searchSubscribers(args);
41
+ break;
42
+ case "create_experiment":
43
+ result = await this.handler.createExperiment(args);
44
+ break;
45
+ case "get_experiment":
46
+ result = await this.handler.getExperiment(args);
47
+ break;
48
+ case "list_experiments":
49
+ result = await this.handler.listExperiments(args);
50
+ break;
51
+ default:
52
+ return wrapError(`Unknown tool: ${name}`);
53
+ }
54
+ return wrapSuccess(result);
55
+ }
56
+ catch (error) {
57
+ return wrapError(error);
58
+ }
59
+ }
60
+ async run() {
61
+ const transport = new StdioServerTransport();
62
+ await this.server.connect(transport);
63
+ console.error("TouchStone MCP server running on stdio");
64
+ }
65
+ }
66
+ // ---- Response helpers ----
67
+ function wrapSuccess(result) {
68
+ return {
69
+ content: [
70
+ {
71
+ type: "text",
72
+ text: JSON.stringify({
73
+ success: true,
74
+ data: result.data,
75
+ message: result.message,
76
+ }, null, 2),
77
+ },
78
+ ],
79
+ };
80
+ }
81
+ function wrapError(error) {
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ return {
84
+ content: [
85
+ {
86
+ type: "text",
87
+ text: JSON.stringify({
88
+ success: false,
89
+ error: message,
90
+ }, null, 2),
91
+ },
92
+ ],
93
+ isError: true,
94
+ };
95
+ }
96
+ // ---- Entry point ----
97
+ import { fileURLToPath } from "url";
98
+ import { realpathSync } from "fs";
99
+ const currentFilePath = fileURLToPath(import.meta.url);
100
+ const entryPointPath = process.argv[1] ? realpathSync(process.argv[1]) : "";
101
+ if (currentFilePath === entryPointPath) {
102
+ const server = new WolvesServer();
103
+ server.run().catch(console.error);
104
+ }
@@ -0,0 +1,9 @@
1
+ export declare class TokenManager {
2
+ private credential;
3
+ private cachedToken;
4
+ private static readonly SCOPE;
5
+ private static readonly BUFFER_MS;
6
+ constructor();
7
+ getToken(): Promise<string>;
8
+ private isTokenValid;
9
+ }
@@ -0,0 +1,32 @@
1
+ import { DefaultAzureCredential } from "@azure/identity";
2
+ 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;
9
+ constructor() {
10
+ this.credential = new DefaultAzureCredential();
11
+ }
12
+ async getToken() {
13
+ if (this.isTokenValid()) {
14
+ return this.cachedToken.token;
15
+ }
16
+ try {
17
+ this.cachedToken = await this.credential.getToken(TokenManager.SCOPE);
18
+ return this.cachedToken.token;
19
+ }
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)}`);
23
+ }
24
+ }
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;
31
+ }
32
+ }
@@ -0,0 +1,134 @@
1
+ import { type AxiosError } from "axios";
2
+ export interface TouchStoneApiConfig {
3
+ baseUrl?: string;
4
+ }
5
+ export interface ExperimentGroupResponse {
6
+ id: string;
7
+ name: string;
8
+ description: string | null;
9
+ created_at: string | null;
10
+ updated_at: string | null;
11
+ }
12
+ export interface ProgressionTemplateResponse {
13
+ id: string;
14
+ experiment_group_id: string;
15
+ name: string;
16
+ description: string | null;
17
+ template_config: Record<string, any>;
18
+ created_at: string;
19
+ updated_at: string;
20
+ }
21
+ export interface AssignmentUnitItem {
22
+ id: string;
23
+ displayName: string;
24
+ description: string | null;
25
+ isArchived: boolean;
26
+ runtimeId: string | null;
27
+ eTag: string | null;
28
+ experimentationGroups: string[];
29
+ }
30
+ export interface AssignmentUnitsListResponse {
31
+ count: number | null;
32
+ value: AssignmentUnitItem[];
33
+ nextLink: string | null;
34
+ nextLinkQuery: string | null;
35
+ }
36
+ export interface SubscriberSearchResult {
37
+ displayName: string;
38
+ mail: string | null;
39
+ mailNickname: string | null;
40
+ userPrincipalName: string | null;
41
+ department: string | null;
42
+ userPhotoBase64: string | null;
43
+ id: string;
44
+ type: string;
45
+ tenantId: string;
46
+ }
47
+ export interface VariantInput {
48
+ id?: string;
49
+ type: "control" | "treatment";
50
+ description?: string;
51
+ }
52
+ export interface MetricInput {
53
+ name: string;
54
+ type: string;
55
+ description?: string;
56
+ }
57
+ export interface ProgressionInput {
58
+ name: string;
59
+ feature_gates?: any[];
60
+ stages?: any[];
61
+ progression_template_id?: string;
62
+ progression_template_parameters?: Record<string, any>;
63
+ progression_parameters?: any[];
64
+ }
65
+ export interface CreateExperimentRequest {
66
+ name: string;
67
+ description?: string;
68
+ experimentation_group: string;
69
+ assignment_unit_id?: string;
70
+ subscribers?: string[];
71
+ variants: VariantInput[];
72
+ metrics?: MetricInput[];
73
+ progressions?: ProgressionInput[];
74
+ }
75
+ export interface ExperimentDetail {
76
+ id: string;
77
+ name: string;
78
+ description: string | null;
79
+ status: string;
80
+ experiment_group_id: string | null;
81
+ experiment_group_name: string | null;
82
+ owner_email: string | null;
83
+ subscribers: string[];
84
+ variants: any[];
85
+ metrics: any[];
86
+ progressions: any[];
87
+ created_at: string;
88
+ updated_at: string;
89
+ started_at: string | null;
90
+ }
91
+ export interface ExperimentSummary {
92
+ id: string;
93
+ name: string;
94
+ description: string | null;
95
+ status: string;
96
+ experiment_group_id: string | null;
97
+ experiment_group_name: string | null;
98
+ owner_email: string | null;
99
+ subscribers: string[];
100
+ variant_count: number;
101
+ metric_count: number;
102
+ progression_count: number;
103
+ created_at: string;
104
+ updated_at: string;
105
+ started_at: string | null;
106
+ }
107
+ export interface PaginatedExperimentsResponse {
108
+ items: ExperimentSummary[];
109
+ total: number;
110
+ page: number;
111
+ page_size: number;
112
+ total_pages: number;
113
+ }
114
+ export declare function normalizeError(error: AxiosError): {
115
+ message: string;
116
+ statusCode: number;
117
+ };
118
+ export declare class WolvesApiClient {
119
+ private client;
120
+ private tokenManager;
121
+ constructor(config?: TouchStoneApiConfig);
122
+ listExperimentGroups(): Promise<ExperimentGroupResponse[]>;
123
+ listProgressionTemplates(groupId: string): Promise<ProgressionTemplateResponse[]>;
124
+ listAssignmentUnits(filter?: string): Promise<AssignmentUnitsListResponse>;
125
+ searchSubscribers(search: string, count?: number, fetchUserPhoto?: boolean): Promise<SubscriberSearchResult[]>;
126
+ createExperiment(data: CreateExperimentRequest): Promise<ExperimentDetail>;
127
+ getExperiment(experimentId: string): Promise<ExperimentDetail>;
128
+ listExperiments(params?: {
129
+ page?: number;
130
+ page_size?: number;
131
+ status?: string;
132
+ search?: string;
133
+ }): Promise<PaginatedExperimentsResponse>;
134
+ }
@@ -0,0 +1,80 @@
1
+ import axios from "axios";
2
+ import { TokenManager } from "./token-manager.js";
3
+ // ---- Error Handling ----
4
+ export function normalizeError(error) {
5
+ if (error.response) {
6
+ const detail = error.response.data?.detail || error.message;
7
+ return { message: detail, statusCode: error.response.status };
8
+ }
9
+ if (error.request) {
10
+ return { message: "No response from TouchStone API", statusCode: 0 };
11
+ }
12
+ return { message: error.message, statusCode: 0 };
13
+ }
14
+ // ---- API Client ----
15
+ export class WolvesApiClient {
16
+ client;
17
+ tokenManager;
18
+ constructor(config) {
19
+ this.tokenManager = new TokenManager();
20
+ const baseURL = config?.baseUrl
21
+ || process.env.TOUCHSTONE_API_BASE_URL
22
+ || "https://touchstone-dev.azurewebsites.net";
23
+ this.client = axios.create({
24
+ baseURL,
25
+ headers: {
26
+ "Content-Type": "application/json",
27
+ "X-Auth-Provider": "microsoft",
28
+ },
29
+ timeout: 30000,
30
+ });
31
+ // Request interceptor: inject Bearer token
32
+ this.client.interceptors.request.use(async (config) => {
33
+ const token = await this.tokenManager.getToken();
34
+ config.headers.Authorization = `Bearer ${token}`;
35
+ return config;
36
+ });
37
+ }
38
+ // ---- Experiment Group endpoints ----
39
+ async listExperimentGroups() {
40
+ const response = await this.client.get("/api/experiments/experiment-groups");
41
+ return response.data;
42
+ }
43
+ // ---- Progression Template endpoints ----
44
+ async listProgressionTemplates(groupId) {
45
+ const response = await this.client.get(`/api/experiments/experiment-groups/${encodeURIComponent(groupId)}/progression-templates`);
46
+ return response.data;
47
+ }
48
+ // ---- Assignment Unit endpoints ----
49
+ async listAssignmentUnits(filter) {
50
+ const params = {};
51
+ if (filter) {
52
+ params.filter = filter;
53
+ }
54
+ const response = await this.client.get("/api/experiments/assignment-units", { params });
55
+ return response.data;
56
+ }
57
+ // ---- Subscriber endpoints ----
58
+ async searchSubscribers(search, count, fetchUserPhoto) {
59
+ const params = {
60
+ search,
61
+ count: count ?? 5,
62
+ fetch_user_photo: fetchUserPhoto ?? false,
63
+ };
64
+ const response = await this.client.get("/api/experiments/subscribers/search", { params });
65
+ return response.data;
66
+ }
67
+ // ---- Experiment endpoints ----
68
+ async createExperiment(data) {
69
+ const response = await this.client.post("/api/experiments/experiments", data);
70
+ return response.data;
71
+ }
72
+ async getExperiment(experimentId) {
73
+ const response = await this.client.get(`/api/experiments/experiments/${experimentId}`);
74
+ return response.data;
75
+ }
76
+ async listExperiments(params) {
77
+ const response = await this.client.get("/api/experiments/experiments", { params });
78
+ return response.data;
79
+ }
80
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "touchstone-mcp-tools",
3
+ "version": "1.0.0",
4
+ "description": "MCP tools for TouchStone Experimentation platform",
5
+ "type": "module",
6
+ "main": "build/index.js",
7
+ "bin": {
8
+ "touchstone-mcp-tools": "build/servers/wolves-server.js"
9
+ },
10
+ "files": [
11
+ "build"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "start": "node build/servers/wolves-server.js",
16
+ "dev": "tsx src/servers/wolves-server.ts",
17
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
18
+ "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.26.0",
23
+ "@azure/identity": "^4.6.0",
24
+ "axios": "^1.7.0"
25
+ },
26
+ "devDependencies": {
27
+ "tsx": "^4.0.0",
28
+ "typescript": "^5.0.0",
29
+ "@types/node": "^22.0.0",
30
+ "jest": "^29.7.0",
31
+ "ts-jest": "^29.2.0",
32
+ "@jest/globals": "^29.7.0"
33
+ },
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ },
37
+ "license": "MIT"
38
+ }