opik-mcp 0.1.2 → 2.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.
@@ -1,420 +1,361 @@
1
- import { makeApiRequest } from '../utils/api.js';
2
1
  import { z } from 'zod';
3
- import { logToFile } from '../utils/logging.js';
4
- export const loadTraceTools = (server) => {
5
- server.tool('list-traces', 'Get a list of traces from a project. Use this for basic trace retrieval and overview', {
6
- page: z.number().optional().default(1).describe('Page number for pagination (starts at 1)'),
7
- size: z
8
- .number()
9
- .optional()
10
- .default(10)
11
- .describe('Number of traces per page (1-100, default 10)'),
12
- projectId: z
13
- .string()
14
- .optional()
15
- .describe('Project ID to filter traces. If not provided, will use the first available project'),
16
- projectName: z
17
- .string()
18
- .optional()
19
- .describe('Project name to filter traces (alternative to projectId). Example: "My AI Assistant"'),
20
- workspaceName: z
21
- .string()
22
- .optional()
23
- .describe('Workspace name to use instead of the default workspace'),
24
- }, async (args) => {
25
- const { page = 1, size = 10, projectId, projectName, workspaceName } = args;
26
- let url = `/v1/private/traces?page=${page}&size=${size}`;
27
- // Add project filtering - API requires either project_id or project_name
28
- if (projectId) {
29
- url += `&project_id=${projectId}`;
30
- }
31
- else if (projectName) {
32
- url += `&project_name=${encodeURIComponent(projectName)}`;
33
- }
34
- else {
35
- // If no project specified, we need to find one for the API to work
36
- const projectsResponse = await makeApiRequest(`/v1/private/projects?page=1&size=1`, {}, workspaceName);
37
- if (projectsResponse.data &&
38
- projectsResponse.data.content &&
39
- projectsResponse.data.content.length > 0) {
40
- const firstProject = projectsResponse.data.content[0];
41
- url += `&project_id=${firstProject.id}`;
42
- logToFile(`No project specified, using first available: ${firstProject.name} (${firstProject.id})`);
2
+ import { buildTraceFilters, callSdk, getOpikApi, getRequestOptions, resolveProjectIdentifier, } from '../utils/opik-sdk.js';
3
+ import { registerTool } from './registration.js';
4
+ import { isoDateSchema, pageSchema, sizeSchema, workspaceNameSchema } from './schema.js';
5
+ export const loadTraceTools = (server, options = {}) => {
6
+ const { includeCoreTools = true, includeExpertActions = true } = options;
7
+ if (includeCoreTools) {
8
+ registerTool(server, 'list-traces', 'List traces for a project for quick inspection and navigation.', {
9
+ page: pageSchema,
10
+ size: sizeSchema(10),
11
+ projectId: z
12
+ .string()
13
+ .optional()
14
+ .describe('Optional project ID. If omitted, the first available project is used.'),
15
+ projectName: z
16
+ .string()
17
+ .optional()
18
+ .describe('Optional project name (alternative to projectId).'),
19
+ workspaceName: workspaceNameSchema,
20
+ }, async (args) => {
21
+ const { page = 1, size = 10, projectId, projectName, workspaceName } = args;
22
+ const resolved = await resolveProjectIdentifier(projectId, projectName, workspaceName);
23
+ if (resolved.error) {
24
+ return {
25
+ content: [{ type: 'text', text: `Error: ${resolved.error}` }],
26
+ };
43
27
  }
44
- else {
28
+ const api = getOpikApi();
29
+ const response = await callSdk(() => api.traces.getTracesByProject({
30
+ page,
31
+ size,
32
+ ...(resolved.projectId && { projectId: resolved.projectId }),
33
+ ...(resolved.projectName && { projectName: resolved.projectName }),
34
+ }, getRequestOptions(workspaceName)));
35
+ if (!response.data) {
45
36
  return {
46
- content: [
47
- {
48
- type: 'text',
49
- text: 'Error: No project ID or name provided, and no projects found',
50
- },
51
- ],
37
+ content: [{ type: 'text', text: response.error || 'Failed to fetch traces' }],
52
38
  };
53
39
  }
54
- }
55
- const response = await makeApiRequest(url, {}, workspaceName);
56
- if (!response.data) {
57
40
  return {
58
- content: [{ type: 'text', text: response.error || 'Failed to fetch traces' }],
41
+ content: [
42
+ {
43
+ type: 'text',
44
+ text: `Found ${response.data.total} traces (showing page ${response.data.page} of ${Math.ceil(response.data.total / response.data.size)})`,
45
+ },
46
+ {
47
+ type: 'text',
48
+ text: JSON.stringify(response.data.content, null, 2),
49
+ },
50
+ ],
59
51
  };
60
- }
61
- return {
62
- content: [
63
- {
64
- type: 'text',
65
- text: `Found ${response.data.total} traces (showing page ${response.data.page} of ${Math.ceil(response.data.total / response.data.size)})`,
66
- },
67
- {
68
- type: 'text',
69
- text: JSON.stringify(response.data.content, null, 2),
70
- },
71
- ],
72
- };
73
- });
74
- server.tool('get-trace-by-id', 'Get detailed information about a specific trace including input, output, metadata, and timing information', {
75
- traceId: z
76
- .string()
77
- .describe('ID of the trace to fetch (UUID format, e.g. "123e4567-e89b-12d3-a456-426614174000")'),
78
- workspaceName: z
79
- .string()
80
- .optional()
81
- .describe('Workspace name to use instead of the default workspace'),
82
- }, async (args) => {
83
- const { traceId, workspaceName } = args;
84
- const response = await makeApiRequest(`/v1/private/traces/${traceId}`, {}, workspaceName);
85
- if (!response.data) {
52
+ }, {
53
+ title: 'List Traces',
54
+ annotations: {
55
+ readOnlyHint: true,
56
+ destructiveHint: false,
57
+ idempotentHint: true,
58
+ openWorldHint: false,
59
+ },
60
+ });
61
+ registerTool(server, 'get-trace-by-id', 'Get full details for a trace, including metadata and serialized input/output.', {
62
+ traceId: z.string().min(1).describe('Trace ID.'),
63
+ workspaceName: workspaceNameSchema,
64
+ }, async (args) => {
65
+ const { traceId, workspaceName } = args;
66
+ const api = getOpikApi();
67
+ const response = await callSdk(() => api.traces.getTraceById(traceId, getRequestOptions(workspaceName)));
68
+ if (!response.data) {
69
+ return {
70
+ content: [{ type: 'text', text: response.error || 'Failed to fetch trace' }],
71
+ };
72
+ }
73
+ const formattedResponse = { ...response.data };
74
+ if (formattedResponse.input &&
75
+ typeof formattedResponse.input === 'object' &&
76
+ Object.keys(formattedResponse.input).length > 0) {
77
+ formattedResponse.input = JSON.stringify(formattedResponse.input, null, 2);
78
+ }
79
+ if (formattedResponse.output &&
80
+ typeof formattedResponse.output === 'object' &&
81
+ Object.keys(formattedResponse.output).length > 0) {
82
+ formattedResponse.output = JSON.stringify(formattedResponse.output, null, 2);
83
+ }
86
84
  return {
87
- content: [{ type: 'text', text: response.error || 'Failed to fetch trace' }],
85
+ content: [
86
+ {
87
+ type: 'text',
88
+ text: `Trace Details for ID: ${traceId}`,
89
+ },
90
+ {
91
+ type: 'text',
92
+ text: JSON.stringify(formattedResponse, null, 2),
93
+ },
94
+ ],
88
95
  };
89
- }
90
- // Format the response for better readability
91
- const formattedResponse = { ...response.data };
92
- // Format input/output if they're large
93
- if (formattedResponse.input &&
94
- typeof formattedResponse.input === 'object' &&
95
- Object.keys(formattedResponse.input).length > 0) {
96
- formattedResponse.input = JSON.stringify(formattedResponse.input, null, 2);
97
- }
98
- if (formattedResponse.output &&
99
- typeof formattedResponse.output === 'object' &&
100
- Object.keys(formattedResponse.output).length > 0) {
101
- formattedResponse.output = JSON.stringify(formattedResponse.output, null, 2);
102
- }
103
- return {
104
- content: [
105
- {
106
- type: 'text',
107
- text: `Trace Details for ID: ${traceId}`,
108
- },
109
- {
110
- type: 'text',
111
- text: JSON.stringify(formattedResponse, null, 2),
112
- },
113
- ],
114
- };
115
- });
116
- server.tool('get-trace-stats', 'Get aggregated statistics for traces including counts, costs, token usage, and performance metrics over time', {
117
- projectId: z
118
- .string()
119
- .optional()
120
- .describe('Project ID to filter traces. If not provided, will use the first available project'),
121
- projectName: z
122
- .string()
123
- .optional()
124
- .describe('Project name to filter traces (alternative to projectId)'),
125
- startDate: z
126
- .string()
127
- .optional()
128
- .describe('Start date in ISO format (YYYY-MM-DD). Example: "2024-01-01"'),
129
- endDate: z
130
- .string()
131
- .optional()
132
- .describe('End date in ISO format (YYYY-MM-DD). Example: "2024-01-31"'),
133
- workspaceName: z
134
- .string()
135
- .optional()
136
- .describe('Workspace name to use instead of the default workspace'),
137
- }, async (args) => {
138
- const { projectId, projectName, startDate, endDate, workspaceName } = args;
139
- let url = `/v1/private/traces/stats`;
140
- // Build query parameters
141
- const queryParams = [];
142
- // Add project filtering - API requires either project_id or project_name
143
- if (projectId) {
144
- queryParams.push(`project_id=${projectId}`);
145
- }
146
- else if (projectName) {
147
- queryParams.push(`project_name=${encodeURIComponent(projectName)}`);
148
- }
149
- else {
150
- // If no project specified, we need to find one for the API to work
151
- const projectsResponse = await makeApiRequest(`/v1/private/projects?page=1&size=1`, {}, workspaceName);
152
- if (projectsResponse.data &&
153
- projectsResponse.data.content &&
154
- projectsResponse.data.content.length > 0) {
155
- const firstProject = projectsResponse.data.content[0];
156
- queryParams.push(`project_id=${firstProject.id}`);
157
- logToFile(`No project specified, using first available: ${firstProject.name} (${firstProject.id})`);
96
+ }, {
97
+ title: 'Get Trace By ID',
98
+ annotations: {
99
+ readOnlyHint: true,
100
+ destructiveHint: false,
101
+ idempotentHint: true,
102
+ openWorldHint: false,
103
+ },
104
+ });
105
+ registerTool(server, 'get-trace-stats', 'Get aggregated trace statistics (count, tokens, cost, and duration) over time.', {
106
+ projectId: z
107
+ .string()
108
+ .optional()
109
+ .describe('Optional project ID. If omitted, the first available project is used.'),
110
+ projectName: z
111
+ .string()
112
+ .optional()
113
+ .describe('Optional project name (alternative to projectId).'),
114
+ startDate: isoDateSchema,
115
+ endDate: isoDateSchema,
116
+ workspaceName: workspaceNameSchema,
117
+ }, async (args) => {
118
+ const { projectId, projectName, startDate, endDate, workspaceName } = args;
119
+ const resolved = await resolveProjectIdentifier(projectId, projectName, workspaceName);
120
+ if (resolved.error) {
121
+ return {
122
+ content: [{ type: 'text', text: `Error: ${resolved.error}` }],
123
+ };
158
124
  }
159
- else {
125
+ const filters = buildTraceFilters(undefined, undefined, startDate, endDate);
126
+ const api = getOpikApi();
127
+ const response = await callSdk(() => api.traces.getTraceStats({
128
+ ...(resolved.projectId && { projectId: resolved.projectId }),
129
+ ...(resolved.projectName && { projectName: resolved.projectName }),
130
+ ...(filters && { filters }),
131
+ }, getRequestOptions(workspaceName)));
132
+ if (!response.data) {
160
133
  return {
161
- content: [
162
- {
163
- type: 'text',
164
- text: 'Error: No project ID or name provided, and no projects found',
165
- },
166
- ],
134
+ content: [{ type: 'text', text: response.error || 'Failed to fetch trace statistics' }],
167
135
  };
168
136
  }
169
- }
170
- if (startDate)
171
- queryParams.push(`start_date=${startDate}`);
172
- if (endDate)
173
- queryParams.push(`end_date=${endDate}`);
174
- if (queryParams.length > 0) {
175
- url += `?${queryParams.join('&')}`;
176
- }
177
- const response = await makeApiRequest(url, {}, workspaceName);
178
- if (!response.data) {
179
137
  return {
180
- content: [{ type: 'text', text: response.error || 'Failed to fetch trace statistics' }],
138
+ content: [
139
+ {
140
+ type: 'text',
141
+ text: `Trace Statistics:`,
142
+ },
143
+ {
144
+ type: 'text',
145
+ text: JSON.stringify(response.data, null, 2),
146
+ },
147
+ ],
181
148
  };
182
- }
183
- return {
184
- content: [
185
- {
186
- type: 'text',
187
- text: `Trace Statistics:`,
188
- },
189
- {
190
- type: 'text',
191
- text: JSON.stringify(response.data, null, 2),
192
- },
193
- ],
194
- };
195
- });
196
- server.tool('search-traces', 'Advanced search for traces with complex filtering and query capabilities', {
197
- projectId: z.string().optional().describe('Project ID to search within'),
198
- projectName: z.string().optional().describe('Project name to search within'),
199
- query: z
200
- .string()
201
- .optional()
202
- .describe('Text query to search in trace names, inputs, outputs, and metadata. Example: "error" or "user_query:hello"'),
203
- filters: z
204
- .record(z.any())
205
- .optional()
206
- .describe('Advanced filters as key-value pairs. Examples: {"status": "error"}, {"model": "gpt-4"}, {"duration_ms": {"$gt": 1000}}'),
207
- page: z.number().optional().default(1).describe('Page number for pagination'),
208
- size: z.number().optional().default(10).describe('Number of traces per page (max 100)'),
209
- sortBy: z
210
- .string()
211
- .optional()
212
- .describe('Field to sort by. Options: "created_at", "duration", "name", "status"'),
213
- sortOrder: z
214
- .enum(['asc', 'desc'])
215
- .optional()
216
- .default('desc')
217
- .describe('Sort order: ascending or descending'),
218
- workspaceName: z.string().optional().describe('Workspace name to use instead of the default'),
219
- }, async (args) => {
220
- const { projectId, projectName, query, filters, page, size, sortBy, sortOrder, workspaceName, } = args;
221
- // Build search request body
222
- const searchBody = {
223
- page: page || 1,
224
- size: size || 10,
225
- };
226
- // Add project filtering
227
- if (projectId) {
228
- searchBody.project_id = projectId;
229
- }
230
- else if (projectName) {
231
- searchBody.project_name = projectName;
232
- }
233
- else {
234
- // If no project specified, we need to find one for the API to work
235
- const projectsResponse = await makeApiRequest(`/v1/private/projects?page=1&size=1`, {}, workspaceName);
236
- if (projectsResponse.data &&
237
- projectsResponse.data.content &&
238
- projectsResponse.data.content.length > 0) {
239
- const firstProject = projectsResponse.data.content[0];
240
- searchBody.project_id = firstProject.id;
241
- logToFile(`No project specified for search, using first available: ${firstProject.name} (${firstProject.id})`);
242
- }
243
- else {
149
+ }, {
150
+ title: 'Get Trace Stats',
151
+ annotations: {
152
+ readOnlyHint: true,
153
+ destructiveHint: false,
154
+ idempotentHint: true,
155
+ openWorldHint: false,
156
+ },
157
+ });
158
+ registerTool(server, 'get-trace-threads', 'List trace threads (conversation/session groupings) or fetch one thread by ID.', {
159
+ projectId: z.string().optional().describe('Optional project ID filter.'),
160
+ projectName: z.string().optional().describe('Optional project name filter.'),
161
+ page: pageSchema,
162
+ size: sizeSchema(10),
163
+ threadId: z
164
+ .string()
165
+ .optional()
166
+ .describe('Optional thread ID. When set, returns that thread instead of paginated listing.'),
167
+ workspaceName: workspaceNameSchema,
168
+ }, async (args) => {
169
+ const { projectId, projectName, page, size, threadId, workspaceName } = args;
170
+ const resolved = await resolveProjectIdentifier(projectId, projectName, workspaceName);
171
+ if (resolved.error) {
244
172
  return {
245
- content: [
246
- {
247
- type: 'text',
248
- text: 'Error: No project ID or name provided, and no projects found',
249
- },
250
- ],
173
+ content: [{ type: 'text', text: `Error: ${resolved.error}` }],
251
174
  };
252
175
  }
253
- }
254
- // Add query if provided
255
- if (query) {
256
- searchBody.query = query;
257
- }
258
- // Add filters if provided
259
- if (filters) {
260
- searchBody.filters = filters;
261
- }
262
- // Add sorting if provided
263
- if (sortBy) {
264
- searchBody.sort_by = sortBy;
265
- if (sortOrder) {
266
- searchBody.sort_order = sortOrder;
176
+ const api = getOpikApi();
177
+ const response = threadId
178
+ ? await callSdk(() => api.traces.getTraceThread({
179
+ threadId,
180
+ ...(resolved.projectId && { projectId: resolved.projectId }),
181
+ ...(resolved.projectName && { projectName: resolved.projectName }),
182
+ }, getRequestOptions(workspaceName)))
183
+ : await callSdk(() => api.traces.getTraceThreads({
184
+ page: page || 1,
185
+ size: size || 10,
186
+ ...(resolved.projectId && { projectId: resolved.projectId }),
187
+ ...(resolved.projectName && { projectName: resolved.projectName }),
188
+ }, getRequestOptions(workspaceName)));
189
+ if (!response.data) {
190
+ return {
191
+ content: [{ type: 'text', text: response.error || 'Failed to fetch trace threads' }],
192
+ };
267
193
  }
268
- }
269
- const response = await makeApiRequest('/v1/private/traces/search', {
270
- method: 'POST',
271
- body: JSON.stringify(searchBody),
272
- }, workspaceName);
273
- if (!response.data) {
274
194
  return {
275
- content: [{ type: 'text', text: response.error || 'Failed to search traces' }],
195
+ content: [
196
+ {
197
+ type: 'text',
198
+ text: threadId
199
+ ? `Thread details for ID: ${threadId}`
200
+ : `Found ${response.data.total || response.data.length || 0} trace threads`,
201
+ },
202
+ {
203
+ type: 'text',
204
+ text: JSON.stringify(response.data, null, 2),
205
+ },
206
+ ],
276
207
  };
277
- }
278
- return {
279
- content: [
280
- {
281
- type: 'text',
282
- text: `Search found ${response.data.total} traces (page ${response.data.page} of ${Math.ceil(response.data.total / response.data.size)})`,
283
- },
284
- {
285
- type: 'text',
286
- text: JSON.stringify(response.data.content, null, 2),
287
- },
288
- ],
289
- };
290
- });
291
- server.tool('get-trace-threads', 'Get trace threads (conversation groupings) to view related traces that belong to the same conversation or session', {
292
- projectId: z.string().optional().describe('Project ID to filter threads'),
293
- projectName: z.string().optional().describe('Project name to filter threads'),
294
- page: z.number().optional().default(1).describe('Page number for pagination'),
295
- size: z.number().optional().default(10).describe('Number of threads per page'),
296
- threadId: z
297
- .string()
298
- .optional()
299
- .describe('Specific thread ID to retrieve (useful for getting all traces in a conversation)'),
300
- workspaceName: z.string().optional().describe('Workspace name to use instead of the default'),
301
- }, async (args) => {
302
- const { projectId, projectName, page, size, threadId, workspaceName } = args;
303
- let url = `/v1/private/traces/threads?page=${page || 1}&size=${size || 10}`;
304
- // Add project filtering
305
- if (projectId) {
306
- url += `&project_id=${projectId}`;
307
- }
308
- else if (projectName) {
309
- url += `&project_name=${encodeURIComponent(projectName)}`;
310
- }
311
- else {
312
- // If no project specified, we need to find one for the API to work
313
- const projectsResponse = await makeApiRequest(`/v1/private/projects?page=1&size=1`, {}, workspaceName);
314
- if (projectsResponse.data &&
315
- projectsResponse.data.content &&
316
- projectsResponse.data.content.length > 0) {
317
- const firstProject = projectsResponse.data.content[0];
318
- url += `&project_id=${firstProject.id}`;
319
- logToFile(`No project specified for threads, using first available: ${firstProject.name} (${firstProject.id})`);
208
+ }, {
209
+ title: 'Get Trace Threads',
210
+ annotations: {
211
+ readOnlyHint: true,
212
+ destructiveHint: false,
213
+ idempotentHint: true,
214
+ openWorldHint: false,
215
+ },
216
+ });
217
+ }
218
+ if (includeExpertActions) {
219
+ registerTool(server, 'search-traces', 'Search traces with optional text query, structured filters, and sorting.', {
220
+ projectId: z.string().optional().describe('Optional project ID to constrain search.'),
221
+ projectName: z.string().optional().describe('Optional project name to constrain search.'),
222
+ query: z
223
+ .string()
224
+ .optional()
225
+ .describe('Optional free-text query across trace name/input/output/metadata.'),
226
+ filters: z
227
+ .record(z.any())
228
+ .optional()
229
+ .describe('Optional advanced filters, e.g. {"status":"error"} or {"duration_ms":{"$gt":1000}}.'),
230
+ page: pageSchema,
231
+ size: sizeSchema(10),
232
+ sortBy: z
233
+ .enum(['created_at', 'duration', 'name', 'status'])
234
+ .optional()
235
+ .describe('Optional sort field.'),
236
+ sortOrder: z.enum(['asc', 'desc']).optional().default('desc').describe('Sort direction.'),
237
+ workspaceName: workspaceNameSchema,
238
+ }, async (args) => {
239
+ const { projectId, projectName, query, filters, page, size, sortBy, sortOrder, workspaceName, } = args;
240
+ const resolved = await resolveProjectIdentifier(projectId, projectName, workspaceName);
241
+ if (resolved.error) {
242
+ return {
243
+ content: [{ type: 'text', text: `Error: ${resolved.error}` }],
244
+ };
245
+ }
246
+ const sdkFilters = buildTraceFilters(query, filters);
247
+ const sorting = sortBy ? `${sortBy}:${sortOrder || 'desc'}` : undefined;
248
+ const api = getOpikApi();
249
+ const response = await callSdk(() => api.traces.getTracesByProject({
250
+ page: page || 1,
251
+ size: size || 10,
252
+ ...(resolved.projectId && { projectId: resolved.projectId }),
253
+ ...(resolved.projectName && { projectName: resolved.projectName }),
254
+ ...(sdkFilters && { filters: sdkFilters }),
255
+ ...(sorting && { sorting }),
256
+ }, getRequestOptions(workspaceName)));
257
+ if (!response.data) {
258
+ return {
259
+ content: [{ type: 'text', text: response.error || 'Failed to search traces' }],
260
+ };
320
261
  }
321
- else {
262
+ return {
263
+ content: [
264
+ {
265
+ type: 'text',
266
+ text: `Search found ${response.data.total} traces (page ${response.data.page} of ${Math.ceil(response.data.total / response.data.size)})`,
267
+ },
268
+ {
269
+ type: 'text',
270
+ text: JSON.stringify(response.data.content, null, 2),
271
+ },
272
+ ],
273
+ };
274
+ }, {
275
+ title: 'Search Traces',
276
+ annotations: {
277
+ readOnlyHint: true,
278
+ destructiveHint: false,
279
+ idempotentHint: true,
280
+ openWorldHint: false,
281
+ },
282
+ });
283
+ registerTool(server, 'add-trace-feedback', 'Attach one or more feedback scores to a trace.', {
284
+ traceId: z.string().min(1).describe('Target trace ID.'),
285
+ scores: z
286
+ .array(z.object({
287
+ name: z
288
+ .string()
289
+ .min(1)
290
+ .describe('Feedback metric name, e.g. relevance, accuracy, helpfulness.'),
291
+ value: z.number().finite().describe('Numeric score value.'),
292
+ reason: z.string().optional().describe('Optional reason for this score.'),
293
+ source: z
294
+ .enum(['ui', 'sdk', 'online_scoring'])
295
+ .optional()
296
+ .default('sdk')
297
+ .describe('Feedback source.'),
298
+ categoryName: z
299
+ .string()
300
+ .optional()
301
+ .describe('Optional category for grouped feedback dimensions.'),
302
+ }))
303
+ .min(1)
304
+ .describe('One or more feedback score objects.'),
305
+ workspaceName: workspaceNameSchema,
306
+ }, async (args) => {
307
+ const { traceId, scores, workspaceName } = args;
308
+ if (!scores || !Array.isArray(scores) || scores.length === 0) {
322
309
  return {
323
310
  content: [
324
311
  {
325
312
  type: 'text',
326
- text: 'Error: No project ID or name provided, and no projects found',
313
+ text: 'Error: At least one feedback score is required. Format: [{"name": "relevance", "value": 0.8}]',
327
314
  },
328
315
  ],
329
316
  };
330
317
  }
331
- }
332
- // Add thread ID filter if specified
333
- if (threadId) {
334
- url += `&thread_id=${encodeURIComponent(threadId)}`;
335
- }
336
- const response = await makeApiRequest(url, {}, workspaceName);
337
- if (!response.data) {
338
- return {
339
- content: [{ type: 'text', text: response.error || 'Failed to fetch trace threads' }],
340
- };
341
- }
342
- return {
343
- content: [
344
- {
345
- type: 'text',
346
- text: threadId
347
- ? `Thread details for ID: ${threadId}`
348
- : `Found ${response.data.total || response.data.length || 0} trace threads`,
349
- },
350
- {
351
- type: 'text',
352
- text: JSON.stringify(response.data, null, 2),
353
- },
354
- ],
355
- };
356
- });
357
- server.tool('add-trace-feedback', 'Add feedback scores to a trace for quality evaluation and monitoring. Useful for rating trace quality, relevance, or custom metrics', {
358
- traceId: z.string().describe('ID of the trace to add feedback to'),
359
- scores: z
360
- .array(z.object({
361
- name: z
362
- .string()
363
- .describe('Name of the feedback metric (e.g., "relevance", "accuracy", "helpfulness", "quality")'),
364
- value: z
365
- .number()
366
- .min(0)
367
- .max(1)
368
- .describe('Score value between 0.0 and 1.0 (0.0 = poor, 1.0 = excellent)'),
369
- reason: z.string().optional().describe('Optional explanation for the score'),
370
- }))
371
- .describe('Array of feedback scores to add. Each score should have a name and value between 0-1'),
372
- workspaceName: z.string().optional().describe('Workspace name to use instead of the default'),
373
- }, async (args) => {
374
- const { traceId, scores, workspaceName } = args;
375
- // Validate scores format
376
- if (!scores || !Array.isArray(scores) || scores.length === 0) {
318
+ const api = getOpikApi();
319
+ for (const score of scores) {
320
+ const response = await callSdk(() => api.traces.addTraceFeedbackScore(traceId, {
321
+ name: score.name,
322
+ value: score.value,
323
+ source: score.source || 'sdk',
324
+ ...(score.reason && { reason: score.reason }),
325
+ ...(score.categoryName && { categoryName: score.categoryName }),
326
+ }, getRequestOptions(workspaceName)));
327
+ if (response.error) {
328
+ return {
329
+ content: [
330
+ {
331
+ type: 'text',
332
+ text: `Error adding feedback: ${response.error}`,
333
+ },
334
+ ],
335
+ };
336
+ }
337
+ }
377
338
  return {
378
339
  content: [
379
340
  {
380
341
  type: 'text',
381
- text: 'Error: At least one feedback score is required. Format: [{"name": "relevance", "value": 0.8}]',
342
+ text: `Successfully added ${scores.length} feedback score(s) to trace ${traceId}`,
382
343
  },
383
- ],
384
- };
385
- }
386
- // Transform scores to the expected API format
387
- const feedbackScores = scores.map((score) => ({
388
- name: score.name,
389
- value: score.value,
390
- ...(score.reason && { reason: score.reason }),
391
- }));
392
- const response = await makeApiRequest(`/v1/private/traces/${traceId}/feedback-scores`, {
393
- method: 'PUT',
394
- body: JSON.stringify({ scores: feedbackScores }),
395
- }, workspaceName);
396
- if (response.error) {
397
- return {
398
- content: [
399
344
  {
400
345
  type: 'text',
401
- text: `Error adding feedback: ${response.error}`,
346
+ text: `Added scores: ${scores.map((s) => `${s.name}: ${s.value}`).join(', ')}`,
402
347
  },
403
348
  ],
404
349
  };
405
- }
406
- return {
407
- content: [
408
- {
409
- type: 'text',
410
- text: `Successfully added ${scores.length} feedback score(s) to trace ${traceId}`,
411
- },
412
- {
413
- type: 'text',
414
- text: `Added scores: ${scores.map((s) => `${s.name}: ${s.value}`).join(', ')}`,
415
- },
416
- ],
417
- };
418
- });
350
+ }, {
351
+ title: 'Add Trace Feedback',
352
+ annotations: {
353
+ readOnlyHint: false,
354
+ destructiveHint: false,
355
+ idempotentHint: false,
356
+ openWorldHint: false,
357
+ },
358
+ });
359
+ }
419
360
  return server;
420
361
  };