touchstone-mcp-tools 1.0.2 → 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.
@@ -45,7 +45,7 @@ export declare class WolvesHandler {
45
45
  }): Promise<ToolResult>;
46
46
  updateSubscribers(params: {
47
47
  experiment_id: string;
48
- subscribers: Array<{
48
+ subscribers?: Array<{
49
49
  id: string;
50
50
  type: string;
51
51
  tenantId: string;
@@ -60,4 +60,25 @@ export declare class WolvesHandler {
60
60
  status?: string;
61
61
  search?: string;
62
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>;
63
84
  }
@@ -1,4 +1,6 @@
1
1
  import { WolvesApiClient } from "../utils/wolves-api.js";
2
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from "fs";
3
+ import { join } from "path";
2
4
  // Generate 8-character variant ID: 4 lowercase letters + 4 digits
3
5
  // Matches ExP-Hub UI convention (e.g., "kifj6658", "tvan3734")
4
6
  function generateVariantId() {
@@ -75,12 +77,16 @@ export class WolvesHandler {
75
77
  }
76
78
  // Tool 6: update_subscribers
77
79
  async updateSubscribers(params) {
78
- await this.api.updateSubscribers(params.experiment_id, {
79
- subscribers: params.subscribers,
80
- });
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 });
81
87
  return {
82
- data: { experiment_id: params.experiment_id, subscriber_count: params.subscribers.length },
83
- message: `Updated ${params.subscribers.length} subscriber(s) for experiment "${params.experiment_id}"`,
88
+ data: { experiment_id: params.experiment_id, subscriber_count: subscribers.length },
89
+ message: `Updated ${subscribers.length} subscriber(s) for experiment "${params.experiment_id}"`,
84
90
  };
85
91
  }
86
92
  // Tool 8: get_experiment
@@ -104,4 +110,45 @@ export class WolvesHandler {
104
110
  message: `Found ${result.items.length} experiment(s) (page ${result.page}/${result.total_pages}, total: ${result.total})`,
105
111
  };
106
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
+ }
107
154
  }
@@ -132,10 +132,10 @@
132
132
  },
133
133
  "update_subscribers": {
134
134
  "name": "update_subscribers",
135
- "description": "Update subscribers for an experiment. Subscribers receive notifications about experiment changes. Requires subscriber objects with id, type, and tenantId (obtained from search_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
136
  "inputSchema": {
137
137
  "type": "object",
138
- "required": ["experiment_id", "subscribers"],
138
+ "required": ["experiment_id"],
139
139
  "properties": {
140
140
  "experiment_id": {
141
141
  "type": "string",
@@ -143,7 +143,7 @@
143
143
  },
144
144
  "subscribers": {
145
145
  "type": "array",
146
- "description": "List of subscriber objects. Each subscriber must include id, type, and tenantId from search_subscribers results.",
146
+ "description": "List of subscriber objects. If omitted, defaults to the current logged-in user.",
147
147
  "items": {
148
148
  "type": "object",
149
149
  "required": ["id", "type", "tenantId"],
@@ -180,5 +180,63 @@
180
180
  "search": { "type": "string", "description": "Search in experiment name" }
181
181
  }
182
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
+ }
183
241
  }
184
242
  }
@@ -51,6 +51,12 @@ export class WolvesServer {
51
51
  case "list_experiments":
52
52
  result = await this.handler.listExperiments(args);
53
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;
54
60
  default:
55
61
  return wrapError(`Unknown tool: ${name}`);
56
62
  }
@@ -82,22 +88,7 @@ function wrapSuccess(result) {
82
88
  };
83
89
  }
84
90
  function wrapError(error) {
85
- let message;
86
- let statusCode;
87
- let detail;
88
- if (error && typeof error === "object" && "response" in error) {
89
- // Axios error — extract API response details
90
- const axiosError = error;
91
- statusCode = axiosError.response?.status;
92
- detail = axiosError.response?.data;
93
- const apiDetail = detail?.detail || detail?.message || detail?.error || detail?.title;
94
- message = apiDetail
95
- ? `API error ${statusCode}: ${typeof apiDetail === "string" ? apiDetail : JSON.stringify(apiDetail)}`
96
- : `Request failed with status code ${statusCode}`;
97
- }
98
- else {
99
- message = error instanceof Error ? error.message : String(error);
100
- }
91
+ const message = error instanceof Error ? error.message : String(error);
101
92
  return {
102
93
  content: [
103
94
  {
@@ -105,8 +96,6 @@ function wrapError(error) {
105
96
  text: JSON.stringify({
106
97
  success: false,
107
98
  error: message,
108
- ...(statusCode ? { statusCode } : {}),
109
- ...(detail ? { detail } : {}),
110
99
  }, null, 2),
111
100
  },
112
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
  }
@@ -127,6 +128,7 @@ export declare class WolvesApiClient {
127
128
  private client;
128
129
  private tokenManager;
129
130
  constructor(config?: TouchStoneApiConfig);
131
+ getCurrentUser(): TokenUserInfo;
130
132
  listExperimentGroups(): Promise<ExperimentGroupResponse[]>;
131
133
  listProgressionTemplates(groupId: string): Promise<ProgressionTemplateResponse[]>;
132
134
  listAssignmentUnits(filter?: string): Promise<AssignmentUnitsListResponse>;
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "touchstone-mcp-tools",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "MCP tools for TouchStone Experimentation platform",
5
5
  "type": "module",
6
6
  "main": "build/index.js",