@critical-path/mcp 0.8.0 → 0.10.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/src/index.test.ts CHANGED
@@ -216,7 +216,7 @@ describe('@critical-path/mcp', () => {
216
216
  expect(await engine.getTask(task.id)).not.toBeNull();
217
217
  });
218
218
 
219
- it('validates tool arguments and strips undeclared fields', async () => {
219
+ it('validates tool arguments and rejects undeclared fields', async () => {
220
220
  const source = await engine.createProject({ name: 'Source', key: 'SRC' });
221
221
  const other = await engine.createProject({ name: 'Other', key: 'OTH' });
222
222
  const task = await engine.createTask({ projectId: source.id, title: 'Stay put' });
@@ -226,13 +226,28 @@ describe('@critical-path/mcp', () => {
226
226
  expect(invalid.isError).toBe(true);
227
227
  expect((invalid.content as Array<{ text: string }>)[0].text).toContain('Invalid arguments');
228
228
 
229
- await client.callTool({
229
+ const undeclared = await client.callTool({
230
230
  name: 'update_task',
231
231
  arguments: { id: task.id, title: 'Renamed', projectId: other.id }
232
232
  });
233
- const updated = await engine.getTask(task.id);
234
- expect(updated?.title).toBe('Renamed');
235
- expect(updated?.projectId).toBe(source.id);
233
+ expect(undeclared.isError).toBe(true);
234
+ expect((undeclared.content as Array<{ text: string }>)[0].text).toContain('projectId');
235
+ const unchanged = await engine.getTask(task.id);
236
+ expect(unchanged?.title).toBe('Stay put');
237
+ expect(unchanged?.projectId).toBe(source.id);
238
+ });
239
+
240
+ it('attributes engine writes to the configured actor', async () => {
241
+ const project = await engine.createProject({ name: 'Actors', key: 'ACT' });
242
+ const task = await engine.createTask({ projectId: project.id, title: 'T' });
243
+
244
+ const defaultClient = await connect({ engine });
245
+ const first = await defaultClient.callTool({ name: 'add_comment', arguments: { taskId: task.id, content: 'hi' } });
246
+ expect(JSON.parse((first.content as Array<{ text: string }>)[0].text).authorId).toBe('mcp-agent');
247
+
248
+ const namedClient = await connect({ engine, actor: { userId: 'claude', actorType: 'agent' } });
249
+ const second = await namedClient.callTool({ name: 'add_comment', arguments: { taskId: task.id, content: 'hi' } });
250
+ expect(JSON.parse((second.content as Array<{ text: string }>)[0].text).authorId).toBe('claude');
236
251
  });
237
252
 
238
253
  it('marks destructive tools with MCP annotations', async () => {
@@ -10,7 +10,7 @@ import {
10
10
  ErrorCode,
11
11
  McpError
12
12
  } from '@modelcontextprotocol/sdk/types.js';
13
- import type { CriticalPathEngine } from '@critical-path/core';
13
+ import type { Actor, CriticalPathEngine } from '@critical-path/core';
14
14
  import type { CriticalPathClient } from '@critical-path/client';
15
15
  import { ALL_TOOLS, parseToolArgs, type BackendContext } from '../tools/definitions.js';
16
16
 
@@ -20,10 +20,20 @@ export interface CriticalPathMcpServerOptions {
20
20
  name?: string;
21
21
  version?: string;
22
22
  tools?: string[];
23
+ /**
24
+ * Identity that changes made through an `engine` are attributed to (activity log, comment
25
+ * authors, ...). Defaults to `DEFAULT_MCP_ACTOR`. With a `client`, identity comes from the API's
26
+ * authentication (e.g. the `Authorization` header passed to the client) instead.
27
+ */
28
+ actor?: Actor;
23
29
  }
24
30
 
31
+ export const DEFAULT_MCP_ACTOR: Actor = Object.freeze({ userId: 'mcp-agent', username: 'MCP Agent', actorType: 'agent' });
32
+
25
33
  export function createCriticalPathMcpServer(options: CriticalPathMcpServerOptions): Server {
26
- const target: BackendContext | undefined = options.engine || options.client;
34
+ const target: BackendContext | undefined = options.engine
35
+ ? options.engine.withActor(options.actor ?? DEFAULT_MCP_ACTOR)
36
+ : options.client;
27
37
  if (!target) {
28
38
  throw new Error('Either engine or client must be provided to createCriticalPathMcpServer.');
29
39
  }
@@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest';
2
2
  import { ALL_TOOLS } from './definitions.js';
3
3
 
4
4
  describe('MCP tool definitions', () => {
5
- // Tool arguments are parsed with zodSchema before execution (unknown keys are stripped),
6
- // while clients see inputSchema. The two must describe the same fields.
5
+ // inputSchema is generated from zodSchema by defineTool; this guards the generation itself
6
+ // (e.g. a zod feature that zod-to-json-schema cannot express would drop fields here).
7
7
  it.each(ALL_TOOLS.map((t) => [t.name, t] as const))('%s: zodSchema and inputSchema agree', (_name, tool) => {
8
8
  const json = tool.inputSchema as { properties?: Record<string, unknown>; required?: string[] };
9
9
  const shape = tool.zodSchema.shape as Record<string, { isOptional(): boolean }>;
@@ -16,4 +16,14 @@ describe('MCP tool definitions', () => {
16
16
  .sort()
17
17
  ).toEqual([...(json.required ?? [])].sort());
18
18
  });
19
+
20
+ it('generates closed object schemas with descriptions for every tool', () => {
21
+ for (const tool of ALL_TOOLS) {
22
+ expect(tool.inputSchema).toMatchObject({ type: 'object', additionalProperties: false });
23
+ expect(tool.inputSchema).not.toHaveProperty('$schema');
24
+ }
25
+ const updateTask = ALL_TOOLS.find((t) => t.name === 'update_task')!;
26
+ expect((updateTask.inputSchema as any).properties.isBlocked.description).toBe('Whether the task is blocked');
27
+ });
19
28
  });
29
+