touchstone-mcp-tools 1.0.1 → 1.0.3

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.
@@ -43,6 +43,14 @@ export declare class WolvesHandler {
43
43
  progression_parameters?: any[];
44
44
  }>;
45
45
  }): Promise<ToolResult>;
46
+ updateSubscribers(params: {
47
+ experiment_id: string;
48
+ subscribers?: Array<{
49
+ id: string;
50
+ type: string;
51
+ tenantId: string;
52
+ }>;
53
+ }): Promise<ToolResult>;
46
54
  getExperiment(params: {
47
55
  experiment_id: string;
48
56
  }): Promise<ToolResult>;
@@ -52,4 +60,25 @@ export declare class WolvesHandler {
52
60
  status?: string;
53
61
  search?: string;
54
62
  }): Promise<ToolResult>;
63
+ logExperimentAction(params: {
64
+ action: string;
65
+ experiment: {
66
+ id: string;
67
+ name: string;
68
+ description?: string;
69
+ status?: string;
70
+ experiment_group?: string;
71
+ assignment_unit?: string;
72
+ progression_template?: string;
73
+ variants?: Array<{
74
+ id: string;
75
+ type: string;
76
+ description?: string;
77
+ }>;
78
+ subscribers?: string[];
79
+ };
80
+ }): Promise<ToolResult>;
81
+ getExperimentHistory(params: {
82
+ limit?: number;
83
+ }): Promise<ToolResult>;
55
84
  }
@@ -1,4 +1,20 @@
1
1
  import { WolvesApiClient } from "../utils/wolves-api.js";
2
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+ // Generate 8-character variant ID: 4 lowercase letters + 4 digits
5
+ // Matches ExP-Hub UI convention (e.g., "kifj6658", "tvan3734")
6
+ function generateVariantId() {
7
+ const letters = "abcdefghijklmnopqrstuvwxyz";
8
+ const digits = "0123456789";
9
+ let result = "";
10
+ for (let i = 0; i < 4; i++) {
11
+ result += letters.charAt(Math.floor(Math.random() * letters.length));
12
+ }
13
+ for (let i = 0; i < 4; i++) {
14
+ result += digits.charAt(Math.floor(Math.random() * digits.length));
15
+ }
16
+ return result;
17
+ }
2
18
  export class WolvesHandler {
3
19
  api;
4
20
  constructor(api) {
@@ -39,13 +55,18 @@ export class WolvesHandler {
39
55
  }
40
56
  // Tool 5: create_experiment
41
57
  async createExperiment(params) {
58
+ // Auto-generate 8-char variant IDs for variants that don't have one
59
+ const variants = params.variants.map((v) => ({
60
+ ...v,
61
+ id: v.id || generateVariantId(),
62
+ }));
42
63
  const result = await this.api.createExperiment({
43
64
  name: params.name,
44
65
  description: params.description,
45
66
  experimentation_group: params.experimentation_group,
46
67
  assignment_unit_id: params.assignment_unit_id,
47
68
  subscribers: params.subscribers,
48
- variants: params.variants,
69
+ variants,
49
70
  metrics: params.metrics,
50
71
  progressions: params.progressions,
51
72
  });
@@ -54,7 +75,21 @@ export class WolvesHandler {
54
75
  message: `Experiment "${result.name}" created (ID: ${result.id})`,
55
76
  };
56
77
  }
57
- // Tool 6: get_experiment
78
+ // Tool 6: update_subscribers
79
+ async updateSubscribers(params) {
80
+ let subscribers = params.subscribers;
81
+ // Default to current user from JWT when no subscribers provided
82
+ if (!subscribers || subscribers.length === 0) {
83
+ const user = this.api.getCurrentUser();
84
+ subscribers = [{ id: user.oid, type: "UserAccount", tenantId: user.tenantId }];
85
+ }
86
+ await this.api.updateSubscribers(params.experiment_id, { subscribers });
87
+ return {
88
+ data: { experiment_id: params.experiment_id, subscriber_count: subscribers.length },
89
+ message: `Updated ${subscribers.length} subscriber(s) for experiment "${params.experiment_id}"`,
90
+ };
91
+ }
92
+ // Tool 8: get_experiment
58
93
  async getExperiment(params) {
59
94
  const result = await this.api.getExperiment(params.experiment_id);
60
95
  return {
@@ -62,7 +97,7 @@ export class WolvesHandler {
62
97
  message: `Retrieved experiment "${result.name}" (status: ${result.status})`,
63
98
  };
64
99
  }
65
- // Tool 7: list_experiments
100
+ // Tool 8: list_experiments
66
101
  async listExperiments(params) {
67
102
  const result = await this.api.listExperiments({
68
103
  page: params.page,
@@ -75,4 +110,45 @@ export class WolvesHandler {
75
110
  message: `Found ${result.items.length} experiment(s) (page ${result.page}/${result.total_pages}, total: ${result.total})`,
76
111
  };
77
112
  }
113
+ // Tool 9: log_experiment_action
114
+ async logExperimentAction(params) {
115
+ const expDir = join(process.cwd(), ".exp");
116
+ if (!existsSync(expDir)) {
117
+ mkdirSync(expDir, { recursive: true });
118
+ }
119
+ const now = new Date();
120
+ const timestamp = now.toISOString();
121
+ const filename = timestamp.replace(/[:.]/g, "-") + ".json";
122
+ const record = {
123
+ action: params.action,
124
+ timestamp,
125
+ experiment: params.experiment,
126
+ };
127
+ writeFileSync(join(expDir, filename), JSON.stringify(record, null, 2));
128
+ return {
129
+ data: record,
130
+ message: `Logged "${params.action}" for experiment "${params.experiment.name}"`,
131
+ };
132
+ }
133
+ // Tool 10: get_experiment_history
134
+ async getExperimentHistory(params) {
135
+ const expDir = join(process.cwd(), ".exp");
136
+ const limit = params.limit ?? 10;
137
+ if (!existsSync(expDir)) {
138
+ return { data: [], message: "No experiment history found" };
139
+ }
140
+ const files = readdirSync(expDir)
141
+ .filter((f) => f.endsWith(".json"))
142
+ .sort()
143
+ .reverse()
144
+ .slice(0, limit);
145
+ const records = files.map((f) => {
146
+ const content = readFileSync(join(expDir, f), "utf8");
147
+ return JSON.parse(content);
148
+ });
149
+ return {
150
+ data: records,
151
+ message: `Found ${records.length} experiment history record(s)`,
152
+ };
153
+ }
78
154
  }
@@ -92,7 +92,7 @@
92
92
  "type": "object",
93
93
  "required": ["type"],
94
94
  "properties": {
95
- "id": { "type": "string", "description": "Variant ID (auto-generated if omitted)" },
95
+ "id": { "type": "string", "description": "Variant ID. 8-character alphanumeric (4 letters + 4 digits, e.g., 'kifj6658'). Auto-generated if omitted." },
96
96
  "type": { "type": "string", "enum": ["control", "treatment"], "description": "Variant type" },
97
97
  "description": { "type": "string", "description": "Variant description" }
98
98
  }
@@ -130,6 +130,33 @@
130
130
  }
131
131
  }
132
132
  },
133
+ "update_subscribers": {
134
+ "name": "update_subscribers",
135
+ "description": "Update subscribers for an experiment. Subscribers receive notifications about experiment changes. If no subscribers are provided, defaults to the current logged-in user (resolved from auth token).",
136
+ "inputSchema": {
137
+ "type": "object",
138
+ "required": ["experiment_id"],
139
+ "properties": {
140
+ "experiment_id": {
141
+ "type": "string",
142
+ "description": "UUID of the experiment to update subscribers for"
143
+ },
144
+ "subscribers": {
145
+ "type": "array",
146
+ "description": "List of subscriber objects. If omitted, defaults to the current logged-in user.",
147
+ "items": {
148
+ "type": "object",
149
+ "required": ["id", "type", "tenantId"],
150
+ "properties": {
151
+ "id": { "type": "string", "description": "Subscriber's user ID (from search_subscribers)" },
152
+ "type": { "type": "string", "description": "Subscriber type (e.g., 'UserAccount')" },
153
+ "tenantId": { "type": "string", "description": "Subscriber's tenant ID (from search_subscribers)" }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+ },
133
160
  "get_experiment": {
134
161
  "name": "get_experiment",
135
162
  "description": "Get detailed information about a specific experiment by its ID.",
@@ -153,5 +180,63 @@
153
180
  "search": { "type": "string", "description": "Search in experiment name" }
154
181
  }
155
182
  }
183
+ },
184
+ "log_experiment_action": {
185
+ "name": "log_experiment_action",
186
+ "description": "Log an experiment action to local history (.exp/ directory). Called after creating an experiment to enable history-based recommendations in future runs.",
187
+ "inputSchema": {
188
+ "type": "object",
189
+ "required": ["action", "experiment"],
190
+ "properties": {
191
+ "action": {
192
+ "type": "string",
193
+ "description": "The action performed (e.g., 'create_experiment')"
194
+ },
195
+ "experiment": {
196
+ "type": "object",
197
+ "required": ["id", "name"],
198
+ "description": "Experiment details to log",
199
+ "properties": {
200
+ "id": { "type": "string", "description": "Experiment UUID" },
201
+ "name": { "type": "string", "description": "Experiment name" },
202
+ "description": { "type": "string", "description": "Experiment description" },
203
+ "status": { "type": "string", "description": "Experiment status" },
204
+ "experiment_group": { "type": "string", "description": "Experiment group ID used" },
205
+ "assignment_unit": { "type": "string", "description": "Assignment unit ID used" },
206
+ "progression_template": { "type": "string", "description": "Progression template ID used" },
207
+ "variants": {
208
+ "type": "array",
209
+ "description": "Variants configured",
210
+ "items": {
211
+ "type": "object",
212
+ "properties": {
213
+ "id": { "type": "string" },
214
+ "type": { "type": "string" },
215
+ "description": { "type": "string" }
216
+ }
217
+ }
218
+ },
219
+ "subscribers": {
220
+ "type": "array",
221
+ "items": { "type": "string" },
222
+ "description": "Subscriber display names"
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }
228
+ },
229
+ "get_experiment_history": {
230
+ "name": "get_experiment_history",
231
+ "description": "Get recent experiment history from local .exp/ directory. Returns the most recent records sorted by timestamp (newest first). Used to provide MRU-based recommendations when selecting experiment configuration.",
232
+ "inputSchema": {
233
+ "type": "object",
234
+ "properties": {
235
+ "limit": {
236
+ "type": "number",
237
+ "description": "Maximum number of history records to return (default: 10)"
238
+ }
239
+ }
240
+ }
156
241
  }
157
242
  }
@@ -42,12 +42,21 @@ export class WolvesServer {
42
42
  case "create_experiment":
43
43
  result = await this.handler.createExperiment(args);
44
44
  break;
45
+ case "update_subscribers":
46
+ result = await this.handler.updateSubscribers(args);
47
+ break;
45
48
  case "get_experiment":
46
49
  result = await this.handler.getExperiment(args);
47
50
  break;
48
51
  case "list_experiments":
49
52
  result = await this.handler.listExperiments(args);
50
53
  break;
54
+ case "log_experiment_action":
55
+ result = await this.handler.logExperimentAction(args);
56
+ break;
57
+ case "get_experiment_history":
58
+ result = await this.handler.getExperimentHistory(args);
59
+ break;
51
60
  default:
52
61
  return wrapError(`Unknown tool: ${name}`);
53
62
  }
@@ -79,22 +88,7 @@ function wrapSuccess(result) {
79
88
  };
80
89
  }
81
90
  function wrapError(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
- }
91
+ const message = error instanceof Error ? error.message : String(error);
98
92
  return {
99
93
  content: [
100
94
  {
@@ -102,8 +96,6 @@ function wrapError(error) {
102
96
  text: JSON.stringify({
103
97
  success: false,
104
98
  error: message,
105
- ...(statusCode ? { statusCode } : {}),
106
- ...(detail ? { detail } : {}),
107
99
  }, null, 2),
108
100
  },
109
101
  ],
@@ -1,8 +1,16 @@
1
+ export interface TokenUserInfo {
2
+ oid: string;
3
+ tenantId: string;
4
+ email: string;
5
+ alias: string;
6
+ name: string;
7
+ }
1
8
  export declare class TokenManager {
2
9
  private cachedTokens;
3
10
  private tokenFilePath;
4
11
  constructor();
5
12
  getToken(): Promise<string>;
13
+ getUserInfo(): TokenUserInfo;
6
14
  private isAccessTokenValid;
7
15
  private refreshAccessToken;
8
16
  private interactiveLogin;
@@ -46,6 +46,22 @@ export class TokenManager {
46
46
  await this.interactiveLogin();
47
47
  return this.cachedTokens.access_token;
48
48
  }
49
+ // ---- Public: User info from JWT ----
50
+ getUserInfo() {
51
+ if (!this.cachedTokens?.access_token) {
52
+ throw new Error("No access token available. Call getToken() first.");
53
+ }
54
+ const payload = this.cachedTokens.access_token.split(".")[1];
55
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString());
56
+ const email = decoded.upn || decoded.unique_name || "";
57
+ return {
58
+ oid: decoded.oid || "",
59
+ tenantId: decoded.tid || "",
60
+ email,
61
+ alias: email.split("@")[0],
62
+ name: decoded.name || "",
63
+ };
64
+ }
49
65
  // ---- Private: Token lifecycle ----
50
66
  isAccessTokenValid() {
51
67
  if (!this.cachedTokens?.access_token)
@@ -1,4 +1,5 @@
1
1
  import { type AxiosError } from "axios";
2
+ import { type TokenUserInfo } from "./token-manager.js";
2
3
  export interface TouchStoneApiConfig {
3
4
  baseUrl?: string;
4
5
  }
@@ -44,6 +45,14 @@ export interface SubscriberSearchResult {
44
45
  type: string;
45
46
  tenantId: string;
46
47
  }
48
+ export interface SubscriberInput {
49
+ id: string;
50
+ type: string;
51
+ tenantId: string;
52
+ }
53
+ export interface UpdateSubscribersRequest {
54
+ subscribers: SubscriberInput[];
55
+ }
47
56
  export interface VariantInput {
48
57
  id?: string;
49
58
  type: "control" | "treatment";
@@ -119,11 +128,13 @@ export declare class WolvesApiClient {
119
128
  private client;
120
129
  private tokenManager;
121
130
  constructor(config?: TouchStoneApiConfig);
131
+ getCurrentUser(): TokenUserInfo;
122
132
  listExperimentGroups(): Promise<ExperimentGroupResponse[]>;
123
133
  listProgressionTemplates(groupId: string): Promise<ProgressionTemplateResponse[]>;
124
134
  listAssignmentUnits(filter?: string): Promise<AssignmentUnitsListResponse>;
125
135
  searchSubscribers(search: string, count?: number, fetchUserPhoto?: boolean): Promise<SubscriberSearchResult[]>;
126
136
  createExperiment(data: CreateExperimentRequest): Promise<ExperimentDetail>;
137
+ updateSubscribers(experimentId: string, data: UpdateSubscribersRequest): Promise<void>;
127
138
  getExperiment(experimentId: string): Promise<ExperimentDetail>;
128
139
  listExperiments(params?: {
129
140
  page?: number;
@@ -36,6 +36,9 @@ export class WolvesApiClient {
36
36
  });
37
37
  }
38
38
  // ---- Experiment Group endpoints ----
39
+ getCurrentUser() {
40
+ return this.tokenManager.getUserInfo();
41
+ }
39
42
  async listExperimentGroups() {
40
43
  const response = await this.client.get("/api/experiments/experiment-groups");
41
44
  return response.data;
@@ -69,6 +72,9 @@ export class WolvesApiClient {
69
72
  const response = await this.client.post("/api/experiments/experiments", data);
70
73
  return response.data;
71
74
  }
75
+ async updateSubscribers(experimentId, data) {
76
+ await this.client.put(`/api/experiments/experiments/${encodeURIComponent(experimentId)}/subscribers`, data);
77
+ }
72
78
  async getExperiment(experimentId) {
73
79
  const response = await this.client.get(`/api/experiments/experiments/${experimentId}`);
74
80
  return response.data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "touchstone-mcp-tools",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "MCP tools for TouchStone Experimentation platform",
5
5
  "type": "module",
6
6
  "main": "build/index.js",