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,62 +1,66 @@
1
- import { makeApiRequest } from '../utils/api.js';
2
1
  import { z } from 'zod';
3
- import { logToFile } from '../utils/logging.js';
2
+ import { callSdk, getOpikApi, getRequestOptions, mapMetricType, resolveProjectIdentifier, } from '../utils/opik-sdk.js';
3
+ import { registerTool } from './registration.js';
4
+ import { isoDateSchema, workspaceNameSchema } from './schema.js';
4
5
  export const loadMetricTools = (server) => {
5
- server.tool('get-metrics', 'Get metrics data', {
6
- metricName: z.string().optional().describe('Optional metric name to filter'),
7
- projectId: z.string().optional().describe('Optional project ID to filter metrics'),
8
- projectName: z.string().optional().describe('Optional project name to filter metrics'),
9
- startDate: z.string().optional().describe('Start date in ISO format (YYYY-MM-DD)'),
10
- endDate: z.string().optional().describe('End date in ISO format (YYYY-MM-DD)'),
6
+ registerTool(server, 'get-metrics', 'Get project metrics for a date range and optional metric type.', {
7
+ metricName: z
8
+ .string()
9
+ .optional()
10
+ .describe('Optional metric type/alias (TRACE_COUNT, TOKEN_USAGE, COST, DURATION, FEEDBACK).'),
11
+ projectId: z.string().optional().describe('Optional project ID.'),
12
+ projectName: z
13
+ .string()
14
+ .optional()
15
+ .describe('Optional project name (alternative to projectId).'),
16
+ startDate: isoDateSchema,
17
+ endDate: isoDateSchema,
18
+ workspaceName: workspaceNameSchema,
11
19
  }, async (args) => {
12
- const { metricName, projectId, projectName, startDate, endDate } = args;
13
- let url = `/v1/private/metrics`;
14
- const queryParams = [];
15
- if (metricName)
16
- queryParams.push(`metric_name=${metricName}`);
17
- // Add project filtering - API requires either project_id or project_name
18
- if (projectId) {
19
- queryParams.push(`project_id=${projectId}`);
20
- }
21
- else if (projectName) {
22
- queryParams.push(`project_name=${encodeURIComponent(projectName)}`);
23
- }
24
- else {
25
- // If no project specified, we need to find one for the API to work
26
- const projectsResponse = await makeApiRequest(`/v1/private/projects?page=1&size=1`);
27
- if (projectsResponse.data &&
28
- projectsResponse.data.content &&
29
- projectsResponse.data.content.length > 0) {
30
- const firstProject = projectsResponse.data.content[0];
31
- queryParams.push(`project_id=${firstProject.id}`);
32
- logToFile(`No project specified, using first available: ${firstProject.name} (${firstProject.id})`);
33
- }
34
- else {
35
- return {
36
- content: [
37
- {
38
- type: 'text',
39
- text: 'Error: No project ID or name provided, and no projects found',
40
- },
41
- ],
42
- };
43
- }
20
+ const { metricName, projectId, projectName, startDate, endDate, workspaceName } = args;
21
+ const resolved = await resolveProjectIdentifier(projectId, projectName, workspaceName);
22
+ if (resolved.error || (!resolved.projectId && !resolved.projectName)) {
23
+ return {
24
+ content: [
25
+ {
26
+ type: 'text',
27
+ text: `Error: ${resolved.error || 'No project available for metrics query'}`,
28
+ },
29
+ ],
30
+ };
44
31
  }
45
- if (startDate)
46
- queryParams.push(`start_date=${startDate}`);
47
- if (endDate)
48
- queryParams.push(`end_date=${endDate}`);
49
- if (queryParams.length > 0) {
50
- url += `?${queryParams.join('&')}`;
32
+ if (!resolved.projectId) {
33
+ return {
34
+ content: [
35
+ {
36
+ type: 'text',
37
+ text: 'Error: Metrics queries require a resolvable project ID',
38
+ },
39
+ ],
40
+ };
51
41
  }
52
- const response = await makeApiRequest(url);
42
+ const metricType = mapMetricType(metricName);
43
+ const api = getOpikApi();
44
+ const response = await callSdk(() => api.projects.getProjectMetrics(resolved.projectId, {
45
+ ...(metricType && { metricType }),
46
+ interval: 'DAILY',
47
+ ...(startDate && { intervalStart: new Date(startDate) }),
48
+ ...(endDate && { intervalEnd: new Date(endDate) }),
49
+ }, getRequestOptions(workspaceName)));
53
50
  if (!response.data) {
54
51
  return {
55
52
  content: [{ type: 'text', text: response.error || 'Failed to fetch metrics' }],
56
53
  };
57
54
  }
55
+ const metricWarning = metricName && !metricType
56
+ ? `\nNote: metricName "${metricName}" is not a known metric type in the SDK and was ignored.`
57
+ : '';
58
58
  return {
59
59
  content: [
60
+ {
61
+ type: 'text',
62
+ text: `Metrics for project ${resolved.projectId}${metricWarning}`,
63
+ },
60
64
  {
61
65
  type: 'text',
62
66
  text: JSON.stringify(response.data, null, 2),
@@ -1,53 +1,74 @@
1
- import { makeApiRequest } from '../utils/api.js';
2
1
  import { z } from 'zod';
3
- export const loadProjectTools = (server) => {
4
- server.tool('list-projects', 'Get a list of projects with optional filtering', {
5
- page: z.number().optional().default(1).describe('Page number for pagination'),
6
- size: z.number().optional().default(10).describe('Number of items per page'),
7
- workspaceName: z.string().optional().describe('Workspace name to use instead of the default'),
8
- }, async (args) => {
9
- const { page, size, workspaceName } = args;
10
- const url = `/v1/private/projects?page=${page}&size=${size}`;
11
- const response = await makeApiRequest(url, {}, workspaceName);
12
- if (!response.data) {
2
+ import { callSdk, getOpikApi, getRequestOptions } from '../utils/opik-sdk.js';
3
+ import { registerTool } from './registration.js';
4
+ import { pageSchema, sizeSchema, workspaceNameSchema } from './schema.js';
5
+ export const loadProjectTools = (server, options = {}) => {
6
+ const { includeReadOps = true, includeMutations = true } = options;
7
+ if (includeReadOps) {
8
+ registerTool(server, 'list-projects', 'List projects in the active workspace to find IDs for traces and metrics operations.', {
9
+ page: pageSchema,
10
+ size: sizeSchema(10),
11
+ workspaceName: workspaceNameSchema,
12
+ }, async (args) => {
13
+ const { page, size, workspaceName } = args;
14
+ const api = getOpikApi();
15
+ const response = await callSdk(() => api.projects.findProjects({ page, size }, getRequestOptions(workspaceName)));
16
+ if (!response.data) {
17
+ return {
18
+ content: [{ type: 'text', text: response.error || 'Failed to fetch projects' }],
19
+ };
20
+ }
13
21
  return {
14
- content: [{ type: 'text', text: response.error || 'Failed to fetch projects' }],
22
+ content: [
23
+ {
24
+ type: 'text',
25
+ text: `Found ${response.data.total} projects (page ${response.data.page} of ${Math.ceil(response.data.total / response.data.size)})`,
26
+ },
27
+ {
28
+ type: 'text',
29
+ text: JSON.stringify(response.data.content, null, 2),
30
+ },
31
+ ],
15
32
  };
16
- }
17
- return {
18
- content: [
19
- {
20
- type: 'text',
21
- text: `Found ${response.data.total} projects (page ${response.data.page} of ${Math.ceil(response.data.total / response.data.size)})`,
22
- },
23
- {
24
- type: 'text',
25
- text: JSON.stringify(response.data.content, null, 2),
26
- },
27
- ],
28
- };
29
- });
30
- server.tool('create-project', 'Create a new project', {
31
- name: z.string().min(1).describe('Name of the project'),
32
- description: z.string().optional().describe('Description of the project'),
33
- workspaceName: z.string().optional().describe('Workspace name to use instead of the default'),
34
- }, async (args) => {
35
- const { name, description, workspaceName } = args;
36
- const requestBody = { name };
37
- if (description)
38
- requestBody.description = description;
39
- const response = await makeApiRequest(`/v1/private/projects`, {
40
- method: 'POST',
41
- body: JSON.stringify(requestBody),
42
- }, workspaceName);
43
- return {
44
- content: [
45
- {
46
- type: 'text',
47
- text: response.error || 'Successfully created project',
48
- },
49
- ],
50
- };
51
- });
33
+ }, {
34
+ title: 'List Projects',
35
+ annotations: {
36
+ readOnlyHint: true,
37
+ destructiveHint: false,
38
+ idempotentHint: true,
39
+ openWorldHint: false,
40
+ },
41
+ });
42
+ }
43
+ if (includeMutations) {
44
+ registerTool(server, 'create-project', 'Create a new project for traces, prompts, and evaluation runs.', {
45
+ name: z.string().min(1).describe('Project name.'),
46
+ description: z.string().optional().describe('Optional project description.'),
47
+ workspaceName: workspaceNameSchema,
48
+ }, async (args) => {
49
+ const { name, description, workspaceName } = args;
50
+ const api = getOpikApi();
51
+ const response = await callSdk(() => api.projects.createProject({
52
+ name,
53
+ ...(description && { description }),
54
+ }, getRequestOptions(workspaceName)));
55
+ return {
56
+ content: [
57
+ {
58
+ type: 'text',
59
+ text: response.error || 'Successfully created project',
60
+ },
61
+ ],
62
+ };
63
+ }, {
64
+ title: 'Create Project',
65
+ annotations: {
66
+ readOnlyHint: false,
67
+ destructiveHint: false,
68
+ idempotentHint: false,
69
+ openWorldHint: false,
70
+ },
71
+ });
72
+ }
52
73
  return server;
53
74
  };
@@ -1,16 +1,16 @@
1
- import { makeApiRequest } from '../utils/api.js';
2
1
  import { z } from 'zod';
2
+ import { callSdk, getOpikApi } from '../utils/opik-sdk.js';
3
+ import { registerTool } from './registration.js';
4
+ import { pageSchema, sizeSchema } from './schema.js';
3
5
  export const loadPromptTools = (server) => {
4
- server.tool('get-prompts', 'Get a list of prompts with optional filtering', {
5
- page: z.number().optional().default(1).describe('Page number for pagination'),
6
- size: z.number().optional().default(10).describe('Number of items per page'),
7
- name: z.string().optional().describe('Filter by prompt name'),
6
+ registerTool(server, 'get-prompts', 'List prompts with optional name filtering.', {
7
+ page: pageSchema,
8
+ size: sizeSchema(10),
9
+ name: z.string().optional().describe('Optional prompt name filter.'),
8
10
  }, async (args) => {
9
11
  const { page, size, name } = args;
10
- let url = `/v1/private/prompts?page=${page}&size=${size}`;
11
- if (name)
12
- url += `&name=${encodeURIComponent(name)}`;
13
- const response = await makeApiRequest(url);
12
+ const api = getOpikApi();
13
+ const response = await callSdk(() => api.prompts.getPrompts({ page, size, name }));
14
14
  if (!response.data) {
15
15
  return {
16
16
  content: [{ type: 'text', text: response.error || 'Failed to fetch prompts' }],
@@ -29,21 +29,18 @@ export const loadPromptTools = (server) => {
29
29
  ],
30
30
  };
31
31
  });
32
- server.tool('create-prompt', 'Create a new prompt', {
33
- name: z.string().min(1).describe('Name of the prompt'),
34
- description: z.string().optional().describe('Description of the prompt'),
35
- tags: z.array(z.string()).optional().describe('List of tags for the prompt'),
32
+ registerTool(server, 'create-prompt', 'Create a prompt definition.', {
33
+ name: z.string().min(1).describe('Prompt name.'),
34
+ description: z.string().optional().describe('Optional prompt description.'),
35
+ tags: z.array(z.string().min(1)).optional().describe('Optional prompt tags.'),
36
36
  }, async (args) => {
37
37
  const { name, description, tags } = args;
38
- const requestBody = { name };
39
- if (description)
40
- requestBody.description = description;
41
- if (tags)
42
- requestBody.tags = tags;
43
- const response = await makeApiRequest(`/v1/private/prompts`, {
44
- method: 'POST',
45
- body: JSON.stringify(requestBody),
46
- });
38
+ const api = getOpikApi();
39
+ const response = await callSdk(() => api.prompts.createPrompt({
40
+ name,
41
+ ...(description && { description }),
42
+ ...(tags && { metadata: { tags } }),
43
+ }));
47
44
  return {
48
45
  content: [
49
46
  {
@@ -53,18 +50,36 @@ export const loadPromptTools = (server) => {
53
50
  ],
54
51
  };
55
52
  });
56
- server.tool('get-prompt-version', 'Retrieve a specific version of a prompt', {
57
- name: z.string().min(1).describe('Name of the prompt'),
58
- commit: z.string().optional().describe('Specific commit/version to retrieve'),
53
+ registerTool(server, 'get-prompt-by-id', 'Get a prompt by ID.', {
54
+ promptId: z.string().min(1).describe('Prompt ID.'),
55
+ }, async (args) => {
56
+ const { promptId } = args;
57
+ const api = getOpikApi();
58
+ const response = await callSdk(() => api.prompts.getPromptById(promptId));
59
+ if (!response.data) {
60
+ return {
61
+ content: [{ type: 'text', text: response.error || 'Failed to fetch prompt' }],
62
+ };
63
+ }
64
+ return {
65
+ content: [
66
+ {
67
+ type: 'text',
68
+ text: JSON.stringify(response.data, null, 2),
69
+ },
70
+ ],
71
+ };
72
+ });
73
+ registerTool(server, 'get-prompt-version', 'Get a specific prompt version by name and optional commit.', {
74
+ name: z.string().min(1).describe('Prompt name.'),
75
+ commit: z.string().optional().describe('Optional commit/version identifier.'),
59
76
  }, async (args) => {
60
77
  const { name, commit } = args;
61
- const requestBody = { name };
62
- if (commit)
63
- requestBody.commit = commit;
64
- const response = await makeApiRequest(`/v1/private/prompts/versions/retrieve`, {
65
- method: 'POST',
66
- body: JSON.stringify(requestBody),
67
- });
78
+ const api = getOpikApi();
79
+ const response = await callSdk(() => api.prompts.retrievePromptVersion({
80
+ name,
81
+ ...(commit && { commit }),
82
+ }));
68
83
  if (!response.data) {
69
84
  return {
70
85
  content: [{ type: 'text', text: response.error || 'Failed to fetch prompt version' }],
@@ -79,25 +94,49 @@ export const loadPromptTools = (server) => {
79
94
  ],
80
95
  };
81
96
  });
82
- server.tool('save-prompt-version', 'Save a new version of a prompt', {
83
- name: z.string().min(1).describe('Name of the prompt'),
84
- template: z.string().describe('Template content for the prompt version'),
85
- change_description: z.string().optional().describe('Description of changes in this version'),
97
+ registerTool(server, 'delete-prompt', 'Delete a prompt by ID.', {
98
+ promptId: z.string().min(1).describe('Prompt ID.'),
99
+ }, async (args) => {
100
+ const { promptId } = args;
101
+ const api = getOpikApi();
102
+ const response = await callSdk(() => api.prompts.deletePrompt(promptId));
103
+ if (response.error) {
104
+ return {
105
+ content: [{ type: 'text', text: response.error || 'Failed to delete prompt' }],
106
+ };
107
+ }
108
+ return {
109
+ content: [
110
+ {
111
+ type: 'text',
112
+ text: `Successfully deleted prompt ${promptId}`,
113
+ },
114
+ ],
115
+ };
116
+ });
117
+ registerTool(server, 'save-prompt-version', 'Create a new prompt version.', {
118
+ name: z.string().min(1).describe('Prompt name.'),
119
+ template: z.string().min(1).describe('Prompt template body.'),
120
+ changeDescription: z
121
+ .string()
122
+ .optional()
123
+ .describe('Optional summary of changes in this version.'),
124
+ change_description: z.string().optional().describe('Deprecated alias for changeDescription.'),
86
125
  metadata: z.record(z.any()).optional().describe('Additional metadata for the prompt version'),
87
- type: z.enum(['mustache', 'jinja2']).optional().describe('Template type'),
126
+ type: z.enum(['mustache', 'jinja2']).optional().describe('Template format.'),
88
127
  }, async (args) => {
89
- const { name, template, change_description, metadata, type } = args;
90
- const version = { template };
91
- if (change_description)
92
- version.change_description = change_description;
93
- if (metadata)
94
- version.metadata = metadata;
95
- if (type)
96
- version.type = type;
97
- const response = await makeApiRequest(`/v1/private/prompts/versions`, {
98
- method: 'POST',
99
- body: JSON.stringify({ name, version }),
100
- });
128
+ const { name, template, change_description, changeDescription, metadata, type } = args;
129
+ const resolvedChangeDescription = changeDescription ?? change_description;
130
+ const api = getOpikApi();
131
+ const response = await callSdk(() => api.prompts.createPromptVersion({
132
+ name,
133
+ version: {
134
+ template,
135
+ ...(resolvedChangeDescription && { changeDescription: resolvedChangeDescription }),
136
+ ...(metadata && { metadata }),
137
+ ...(type && { type }),
138
+ },
139
+ }));
101
140
  if (!response.data) {
102
141
  return {
103
142
  content: [{ type: 'text', text: response.error || 'Failed to create prompt version' }],
@@ -0,0 +1,110 @@
1
+ import { runWithRequestContext } from '../utils/request-context.js';
2
+ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import config from '../config.js';
4
+ const MISSING_API_KEY_MESSAGE = [
5
+ 'This Opik MCP request requires an API key.',
6
+ 'Set OPIK_API_KEY in the environment where the server runs,',
7
+ 'or send Authorization: Bearer <token> with MCP requests.',
8
+ 'If you are onboarding in a coding agent or MCP client, start with setup guidance tools',
9
+ 'like get-opik-help or get-server-info, then add your key and retry.',
10
+ ].join(' ');
11
+ function inferAnnotations(name) {
12
+ const readPrefixes = ['get-', 'list-', 'search-', 'read-'];
13
+ const mutatePrefixes = ['create-', 'delete-', 'update-', 'add-', 'save-'];
14
+ if (readPrefixes.some(prefix => name.startsWith(prefix))) {
15
+ return {
16
+ readOnlyHint: true,
17
+ destructiveHint: false,
18
+ idempotentHint: true,
19
+ openWorldHint: false,
20
+ };
21
+ }
22
+ if (mutatePrefixes.some(prefix => name.startsWith(prefix))) {
23
+ return {
24
+ readOnlyHint: false,
25
+ destructiveHint: name.startsWith('delete-'),
26
+ idempotentHint: false,
27
+ openWorldHint: false,
28
+ };
29
+ }
30
+ return undefined;
31
+ }
32
+ function withRequestContext(handler, requiresApiKey = true) {
33
+ return (...args) => {
34
+ const extra = [...args]
35
+ .reverse()
36
+ .find(arg => arg && typeof arg === 'object' && 'authInfo' in arg);
37
+ const authInfo = extra?.authInfo;
38
+ const context = {
39
+ apiKey: authInfo?.token,
40
+ workspaceName: authInfo?.extra?.workspaceName,
41
+ };
42
+ if (requiresApiKey && !(context.apiKey || config.apiKey)) {
43
+ return {
44
+ content: [
45
+ {
46
+ type: 'text',
47
+ text: MISSING_API_KEY_MESSAGE,
48
+ },
49
+ ],
50
+ };
51
+ }
52
+ return runWithRequestContext(context, () => handler(...args));
53
+ };
54
+ }
55
+ export function registerTool(server, name, description, inputSchema, handler, options = {}) {
56
+ const wrappedHandler = withRequestContext(handler, options.requiresApiKey !== false);
57
+ if (typeof server.registerTool === 'function') {
58
+ const inferredAnnotations = inferAnnotations(name);
59
+ const mergedAnnotations = {
60
+ ...inferredAnnotations,
61
+ ...options.annotations,
62
+ };
63
+ server.registerTool(name, {
64
+ ...(options.title && { title: options.title }),
65
+ description,
66
+ inputSchema,
67
+ ...(Object.keys(mergedAnnotations).length > 0 && { annotations: mergedAnnotations }),
68
+ ...(options.outputSchema && { outputSchema: options.outputSchema }),
69
+ ...(options._meta && { _meta: options._meta }),
70
+ }, wrappedHandler);
71
+ return;
72
+ }
73
+ server.tool(name, description, inputSchema, wrappedHandler);
74
+ }
75
+ export function registerResource(server, name, uri, description, readCallback) {
76
+ const wrappedReadCallback = withRequestContext(readCallback);
77
+ if (typeof server.registerResource === 'function') {
78
+ server.registerResource(name, uri, {
79
+ description,
80
+ }, wrappedReadCallback);
81
+ return;
82
+ }
83
+ server.resource(name, uri, wrappedReadCallback);
84
+ }
85
+ export function registerResourceTemplate(server, name, uriTemplate, description, readCallback, listCallback) {
86
+ const wrappedReadCallback = withRequestContext(readCallback);
87
+ const wrappedListCallback = listCallback ? withRequestContext(listCallback) : undefined;
88
+ const template = new ResourceTemplate(uriTemplate, {
89
+ list: wrappedListCallback,
90
+ });
91
+ if (typeof server.registerResource === 'function') {
92
+ server.registerResource(name, template, {
93
+ description,
94
+ }, wrappedReadCallback);
95
+ return;
96
+ }
97
+ server.resource(name, template, wrappedReadCallback);
98
+ }
99
+ export function registerPrompt(server, name, description, argsSchema, handler, options = {}) {
100
+ const wrappedHandler = withRequestContext(handler);
101
+ if (typeof server.registerPrompt === 'function') {
102
+ server.registerPrompt(name, {
103
+ ...(options.title && { title: options.title }),
104
+ description,
105
+ argsSchema,
106
+ }, wrappedHandler);
107
+ return;
108
+ }
109
+ server.prompt(name, description, argsSchema, wrappedHandler);
110
+ }
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ export const pageSchema = z.number().int().min(1).default(1).describe('1-based page number.');
3
+ export const sizeSchema = (defaultSize, max = 100) => z.number().int().min(1).max(max).default(defaultSize).describe(`Page size (1-${max}).`);
4
+ export const workspaceNameSchema = z
5
+ .string()
6
+ .min(1)
7
+ .optional()
8
+ .describe('Workspace override for local/stdio mode. Ignored when remote token-to-workspace mapping is enforced.');
9
+ export const isoDateSchema = z
10
+ .string()
11
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
12
+ .optional()
13
+ .describe('Date in YYYY-MM-DD format.');