@opencapstack/mcp-server 0.1.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.
Files changed (77) hide show
  1. package/README.md +166 -0
  2. package/dist/auth.d.ts +7 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +16 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/client.d.ts +6 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +30 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +59 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/server.d.ts +10 -0
  15. package/dist/server.d.ts.map +1 -0
  16. package/dist/server.js +73 -0
  17. package/dist/server.js.map +1 -0
  18. package/dist/tools/dilution.d.ts +3 -0
  19. package/dist/tools/dilution.d.ts.map +1 -0
  20. package/dist/tools/dilution.js +51 -0
  21. package/dist/tools/dilution.js.map +1 -0
  22. package/dist/tools/documents.d.ts +3 -0
  23. package/dist/tools/documents.d.ts.map +1 -0
  24. package/dist/tools/documents.js +72 -0
  25. package/dist/tools/documents.js.map +1 -0
  26. package/dist/tools/equityPlans.d.ts +3 -0
  27. package/dist/tools/equityPlans.d.ts.map +1 -0
  28. package/dist/tools/equityPlans.js +65 -0
  29. package/dist/tools/equityPlans.js.map +1 -0
  30. package/dist/tools/financialReports.d.ts +3 -0
  31. package/dist/tools/financialReports.d.ts.map +1 -0
  32. package/dist/tools/financialReports.js +79 -0
  33. package/dist/tools/financialReports.js.map +1 -0
  34. package/dist/tools/safes.d.ts +3 -0
  35. package/dist/tools/safes.d.ts.map +1 -0
  36. package/dist/tools/safes.js +102 -0
  37. package/dist/tools/safes.js.map +1 -0
  38. package/dist/tools/shareClasses.d.ts +3 -0
  39. package/dist/tools/shareClasses.d.ts.map +1 -0
  40. package/dist/tools/shareClasses.js +63 -0
  41. package/dist/tools/shareClasses.js.map +1 -0
  42. package/dist/tools/stakeholders.d.ts +3 -0
  43. package/dist/tools/stakeholders.d.ts.map +1 -0
  44. package/dist/tools/stakeholders.js +72 -0
  45. package/dist/tools/stakeholders.js.map +1 -0
  46. package/dist/tools/valuations.d.ts +3 -0
  47. package/dist/tools/valuations.d.ts.map +1 -0
  48. package/dist/tools/valuations.js +67 -0
  49. package/dist/tools/valuations.js.map +1 -0
  50. package/dist/tools/waterfall.d.ts +3 -0
  51. package/dist/tools/waterfall.d.ts.map +1 -0
  52. package/dist/tools/waterfall.js +46 -0
  53. package/dist/tools/waterfall.js.map +1 -0
  54. package/dist/types.d.ts +14 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +5 -0
  57. package/dist/types.js.map +1 -0
  58. package/package.json +57 -0
  59. package/src/auth.ts +19 -0
  60. package/src/client.ts +46 -0
  61. package/src/index.ts +70 -0
  62. package/src/server.ts +93 -0
  63. package/src/tools/dilution.ts +54 -0
  64. package/src/tools/documents.ts +74 -0
  65. package/src/tools/equityPlans.ts +67 -0
  66. package/src/tools/financialReports.ts +82 -0
  67. package/src/tools/safes.ts +104 -0
  68. package/src/tools/shareClasses.ts +65 -0
  69. package/src/tools/stakeholders.ts +74 -0
  70. package/src/tools/valuations.ts +73 -0
  71. package/src/tools/waterfall.ts +48 -0
  72. package/src/types.ts +16 -0
  73. package/tests/auth.test.ts +54 -0
  74. package/tests/server.test.ts +95 -0
  75. package/tests/tools/shareClasses.test.ts +133 -0
  76. package/tests/tools/stakeholders.test.ts +147 -0
  77. package/tsconfig.json +16 -0
@@ -0,0 +1,74 @@
1
+ import { z } from 'zod';
2
+ import { type ToolDefinition } from '../types.js';
3
+
4
+ export const stakeholderTools: ToolDefinition[] = [
5
+ {
6
+ name: 'list_stakeholders',
7
+ description:
8
+ 'List all stakeholders in the cap table. Returns name, email, role, and ownership details.',
9
+ inputSchema: z.object({
10
+ companyId: z.string().optional().describe('Filter by company ID'),
11
+ limit: z.number().optional().default(50).describe('Max results to return'),
12
+ }),
13
+ handler: async (input, client) => {
14
+ const { data } = await client.get('/api/v1/stakeholders', { params: input });
15
+ const stakeholders = data.stakeholders ?? data;
16
+ return {
17
+ content: [{ type: 'text', text: JSON.stringify(stakeholders, null, 2) }],
18
+ };
19
+ },
20
+ },
21
+ {
22
+ name: 'get_stakeholder',
23
+ description: 'Get details for a specific stakeholder by ID.',
24
+ inputSchema: z.object({
25
+ id: z.string().describe('Stakeholder ID'),
26
+ }),
27
+ handler: async (input, client) => {
28
+ const { data } = await client.get(`/api/v1/stakeholders/${input.id}`);
29
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
30
+ },
31
+ },
32
+ {
33
+ name: 'create_stakeholder',
34
+ description: 'Add a new stakeholder to the cap table.',
35
+ inputSchema: z.object({
36
+ name: z.string().describe('Full name'),
37
+ email: z.string().email().describe('Email address'),
38
+ role: z
39
+ .enum(['founder', 'investor', 'employee', 'advisor', 'other'])
40
+ .describe('Stakeholder role'),
41
+ companyId: z.string().describe('Company ID to add the stakeholder to'),
42
+ }),
43
+ handler: async (input, client) => {
44
+ const { data } = await client.post('/api/v1/stakeholders', input);
45
+ return {
46
+ content: [
47
+ { type: 'text', text: `Stakeholder created: ${JSON.stringify(data, null, 2)}` },
48
+ ],
49
+ };
50
+ },
51
+ },
52
+ {
53
+ name: 'update_stakeholder',
54
+ description: 'Update an existing stakeholder by ID.',
55
+ inputSchema: z.object({
56
+ id: z.string().describe('Stakeholder ID'),
57
+ name: z.string().optional().describe('Full name'),
58
+ email: z.string().email().optional().describe('Email address'),
59
+ role: z
60
+ .enum(['founder', 'investor', 'employee', 'advisor', 'other'])
61
+ .optional()
62
+ .describe('Stakeholder role'),
63
+ }),
64
+ handler: async (input, client) => {
65
+ const { id, ...body } = input;
66
+ const { data } = await client.put(`/api/v1/stakeholders/${id}`, body);
67
+ return {
68
+ content: [
69
+ { type: 'text', text: `Stakeholder updated: ${JSON.stringify(data, null, 2)}` },
70
+ ],
71
+ };
72
+ },
73
+ },
74
+ ];
@@ -0,0 +1,73 @@
1
+ import { z } from 'zod';
2
+ import { type ToolDefinition } from '../types.js';
3
+
4
+ export const valuationTools: ToolDefinition[] = [
5
+ {
6
+ name: 'get_latest_valuation',
7
+ description:
8
+ 'Get the most recent 409A or board-approved valuation for the company.',
9
+ inputSchema: z.object({
10
+ companyId: z.string().describe('Company ID'),
11
+ }),
12
+ handler: async (input, client) => {
13
+ const { data } = await client.get(
14
+ `/api/v1/valuations/latest`,
15
+ { params: { companyId: input.companyId } }
16
+ );
17
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
18
+ },
19
+ },
20
+ {
21
+ name: 'get_valuation_history',
22
+ description: 'Get the historical valuation timeline for the company.',
23
+ inputSchema: z.object({
24
+ companyId: z.string().describe('Company ID'),
25
+ limit: z.number().optional().default(20).describe('Max results to return'),
26
+ }),
27
+ handler: async (input, client) => {
28
+ const { data } = await client.get('/api/v1/valuations', { params: input });
29
+ const valuations = data.valuations ?? data;
30
+ return {
31
+ content: [{ type: 'text', text: JSON.stringify(valuations, null, 2) }],
32
+ };
33
+ },
34
+ },
35
+ {
36
+ name: 'create_valuation_request',
37
+ description:
38
+ 'Submit a new 409A valuation request or record a board-approved valuation.',
39
+ inputSchema: z.object({
40
+ companyId: z.string().describe('Company ID'),
41
+ valuationType: z
42
+ .enum(['409A', 'board_approved', 'preferred_round', 'other'])
43
+ .describe('Type of valuation'),
44
+ valuationDate: z.string().describe('Effective date in ISO 8601 format (YYYY-MM-DD)'),
45
+ commonStockFMV: z
46
+ .number()
47
+ .positive()
48
+ .describe('Fair market value per common share in USD'),
49
+ postMoneyValuation: z
50
+ .number()
51
+ .positive()
52
+ .optional()
53
+ .describe('Total post-money company valuation in USD'),
54
+ provider: z
55
+ .string()
56
+ .optional()
57
+ .describe('Name of the 409A valuation provider or firm'),
58
+ reportUrl: z
59
+ .string()
60
+ .url()
61
+ .optional()
62
+ .describe('URL to the valuation report document'),
63
+ }),
64
+ handler: async (input, client) => {
65
+ const { data } = await client.post('/api/v1/valuations', input);
66
+ return {
67
+ content: [
68
+ { type: 'text', text: `Valuation recorded: ${JSON.stringify(data, null, 2)}` },
69
+ ],
70
+ };
71
+ },
72
+ },
73
+ ];
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod';
2
+ import { type ToolDefinition } from '../types.js';
3
+
4
+ export const waterfallTools: ToolDefinition[] = [
5
+ {
6
+ name: 'run_waterfall_analysis',
7
+ description:
8
+ 'Run a waterfall analysis to model how exit proceeds would be distributed among ' +
9
+ 'stakeholders given their liquidation preferences, participation rights, and ownership. ' +
10
+ 'Returns proceeds per stakeholder and per share class at the given exit amount.',
11
+ inputSchema: z.object({
12
+ companyId: z.string().describe('Company ID'),
13
+ exitAmount: z
14
+ .number()
15
+ .positive()
16
+ .describe('Total exit/acquisition proceeds in USD'),
17
+ exitType: z
18
+ .enum(['acquisition', 'ipo', 'dissolution'])
19
+ .optional()
20
+ .default('acquisition')
21
+ .describe('Type of exit event'),
22
+ deductTransactionCosts: z
23
+ .boolean()
24
+ .optional()
25
+ .default(false)
26
+ .describe('Whether to deduct estimated transaction costs before distribution'),
27
+ transactionCostsAmount: z
28
+ .number()
29
+ .optional()
30
+ .describe('Transaction costs amount in USD (used if deductTransactionCosts is true)'),
31
+ includeOptionPoolSweep: z
32
+ .boolean()
33
+ .optional()
34
+ .default(false)
35
+ .describe('Whether to include pre-exit option pool sweep'),
36
+ asOfDate: z
37
+ .string()
38
+ .optional()
39
+ .describe('Run analysis as of a specific date (ISO 8601 YYYY-MM-DD). Defaults to today.'),
40
+ }),
41
+ handler: async (input, client) => {
42
+ const { data } = await client.post('/api/v1/waterfall/analyze', input);
43
+ return {
44
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
45
+ };
46
+ },
47
+ },
48
+ ];
package/src/types.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared types used across tools and the server.
3
+ */
4
+
5
+ import { type z } from 'zod';
6
+ import { type AxiosInstance } from 'axios';
7
+ import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
8
+
9
+ export type ToolResult = CallToolResult;
10
+
11
+ export interface ToolDefinition<TSchema extends z.ZodTypeAny = z.ZodTypeAny> {
12
+ name: string;
13
+ description: string;
14
+ inputSchema: TSchema;
15
+ handler: (input: z.infer<TSchema>, client: AxiosInstance) => Promise<ToolResult>;
16
+ }
@@ -0,0 +1,54 @@
1
+ import { getApiKey, getBaseUrl } from '../src/auth.js';
2
+
3
+ describe('getApiKey', () => {
4
+ const ORIGINAL = process.env.OPENCAP_API_KEY;
5
+
6
+ afterEach(() => {
7
+ if (ORIGINAL === undefined) {
8
+ delete process.env.OPENCAP_API_KEY;
9
+ } else {
10
+ process.env.OPENCAP_API_KEY = ORIGINAL;
11
+ }
12
+ });
13
+
14
+ it('throws a helpful error when OPENCAP_API_KEY is not set', () => {
15
+ delete process.env.OPENCAP_API_KEY;
16
+ expect(() => getApiKey()).toThrow(
17
+ 'Set OPENCAP_API_KEY to your OpenCap JWT token.'
18
+ );
19
+ });
20
+
21
+ it('throws an error that includes the login URL', () => {
22
+ delete process.env.OPENCAP_API_KEY;
23
+ expect(() => getApiKey()).toThrow(
24
+ 'https://api.opencapstack.com/api/v1/auth/login'
25
+ );
26
+ });
27
+
28
+ it('returns the key when OPENCAP_API_KEY is set', () => {
29
+ process.env.OPENCAP_API_KEY = 'test-api-key-abc123';
30
+ expect(getApiKey()).toBe('test-api-key-abc123');
31
+ });
32
+ });
33
+
34
+ describe('getBaseUrl', () => {
35
+ const ORIGINAL = process.env.OPENCAP_BASE_URL;
36
+
37
+ afterEach(() => {
38
+ if (ORIGINAL === undefined) {
39
+ delete process.env.OPENCAP_BASE_URL;
40
+ } else {
41
+ process.env.OPENCAP_BASE_URL = ORIGINAL;
42
+ }
43
+ });
44
+
45
+ it('defaults to https://api.opencapstack.com when OPENCAP_BASE_URL is not set', () => {
46
+ delete process.env.OPENCAP_BASE_URL;
47
+ expect(getBaseUrl()).toBe('https://api.opencapstack.com');
48
+ });
49
+
50
+ it('returns the custom base URL when OPENCAP_BASE_URL is set', () => {
51
+ process.env.OPENCAP_BASE_URL = 'https://self-hosted.example.com';
52
+ expect(getBaseUrl()).toBe('https://self-hosted.example.com');
53
+ });
54
+ });
@@ -0,0 +1,95 @@
1
+ import { createServer, ALL_TOOLS } from '../src/server.js';
2
+ import { createClient } from '../src/client.js';
3
+
4
+ // Mock axios so createClient works without a real network
5
+ jest.mock('axios', () => {
6
+ const mockClient = {
7
+ get: jest.fn(),
8
+ post: jest.fn(),
9
+ put: jest.fn(),
10
+ interceptors: {
11
+ response: { use: jest.fn() },
12
+ },
13
+ defaults: { headers: { common: {} } },
14
+ };
15
+ const axios = {
16
+ create: jest.fn(() => mockClient),
17
+ default: { create: jest.fn(() => mockClient) },
18
+ };
19
+ return { ...axios, default: axios };
20
+ });
21
+
22
+ // Provide a fake API key so auth doesn't throw
23
+ process.env.OPENCAP_API_KEY = 'test-server-key';
24
+ process.env.OPENCAP_BASE_URL = 'https://api.opencapstack.com';
25
+
26
+ describe('ALL_TOOLS registry', () => {
27
+ const toolNames = ALL_TOOLS.map((t) => t.name);
28
+
29
+ it('contains all stakeholder tools', () => {
30
+ expect(toolNames).toContain('list_stakeholders');
31
+ expect(toolNames).toContain('get_stakeholder');
32
+ expect(toolNames).toContain('create_stakeholder');
33
+ expect(toolNames).toContain('update_stakeholder');
34
+ });
35
+
36
+ it('contains all share class tools', () => {
37
+ expect(toolNames).toContain('list_share_classes');
38
+ expect(toolNames).toContain('get_share_class');
39
+ expect(toolNames).toContain('create_share_class');
40
+ });
41
+
42
+ it('contains all equity plan tools', () => {
43
+ expect(toolNames).toContain('list_equity_plans');
44
+ expect(toolNames).toContain('get_equity_plan');
45
+ expect(toolNames).toContain('create_equity_plan');
46
+ });
47
+
48
+ it('contains all SAFE tools', () => {
49
+ expect(toolNames).toContain('list_safes');
50
+ expect(toolNames).toContain('get_safe');
51
+ expect(toolNames).toContain('create_safe');
52
+ expect(toolNames).toContain('update_safe');
53
+ });
54
+
55
+ it('contains all document tools', () => {
56
+ expect(toolNames).toContain('list_documents');
57
+ expect(toolNames).toContain('get_document');
58
+ expect(toolNames).toContain('search_documents');
59
+ });
60
+
61
+ it('contains all valuation tools', () => {
62
+ expect(toolNames).toContain('get_latest_valuation');
63
+ expect(toolNames).toContain('get_valuation_history');
64
+ expect(toolNames).toContain('create_valuation_request');
65
+ });
66
+
67
+ it('contains all dilution tools', () => {
68
+ expect(toolNames).toContain('calculate_dilution');
69
+ expect(toolNames).toContain('get_fully_diluted_shares');
70
+ });
71
+
72
+ it('contains the waterfall tool', () => {
73
+ expect(toolNames).toContain('run_waterfall_analysis');
74
+ });
75
+
76
+ it('contains all financial report tools', () => {
77
+ expect(toolNames).toContain('list_financial_reports');
78
+ expect(toolNames).toContain('get_financial_report');
79
+ expect(toolNames).toContain('create_financial_report');
80
+ });
81
+
82
+ it('has no duplicate tool names', () => {
83
+ const unique = new Set(toolNames);
84
+ expect(unique.size).toBe(toolNames.length);
85
+ });
86
+ });
87
+
88
+ describe('createServer', () => {
89
+ it('returns a server instance', () => {
90
+ const client = createClient('test-key');
91
+ const server = createServer(client);
92
+ expect(server).toBeDefined();
93
+ expect(typeof server.connect).toBe('function');
94
+ });
95
+ });
@@ -0,0 +1,133 @@
1
+ import { shareClassTools } from '../../src/tools/shareClasses.js';
2
+ import { type AxiosInstance } from 'axios';
3
+ import { type ToolResult } from '../../src/types.js';
4
+
5
+ function makeClient(overrides: Partial<AxiosInstance> = {}): AxiosInstance {
6
+ return {
7
+ get: jest.fn(),
8
+ post: jest.fn(),
9
+ put: jest.fn(),
10
+ delete: jest.fn(),
11
+ ...overrides,
12
+ } as unknown as AxiosInstance;
13
+ }
14
+
15
+ /** Narrow the first content item to a text block and return its text. */
16
+ function firstText(result: ToolResult): string {
17
+ const item = result.content[0];
18
+ if (item.type !== 'text') throw new Error(`Expected text content, got ${item.type}`);
19
+ return item.text;
20
+ }
21
+
22
+ const listTool = shareClassTools.find((t) => t.name === 'list_share_classes')!;
23
+ const getTool = shareClassTools.find((t) => t.name === 'get_share_class')!;
24
+ const createTool = shareClassTools.find((t) => t.name === 'create_share_class')!;
25
+
26
+ describe('list_share_classes', () => {
27
+ it('calls GET /api/v1/share-classes', async () => {
28
+ const client = makeClient({
29
+ get: jest.fn().mockResolvedValue({
30
+ data: [{ id: 'sc-1', name: 'Common' }],
31
+ }),
32
+ });
33
+
34
+ const result = await listTool.handler({}, client);
35
+
36
+ expect(client.get).toHaveBeenCalledWith('/api/v1/share-classes', { params: {} });
37
+ expect(firstText(result)).toContain('Common');
38
+ });
39
+
40
+ it('unwraps the shareClasses envelope if present', async () => {
41
+ const shareClasses = [{ id: 'sc-2', name: 'Series A Preferred' }];
42
+ const client = makeClient({
43
+ get: jest.fn().mockResolvedValue({ data: { shareClasses } }),
44
+ });
45
+
46
+ const result = await listTool.handler({}, client);
47
+ expect(firstText(result)).toContain('Series A Preferred');
48
+ });
49
+
50
+ it('passes companyId and limit as params', async () => {
51
+ const client = makeClient({
52
+ get: jest.fn().mockResolvedValue({ data: [] }),
53
+ });
54
+
55
+ await listTool.handler({ companyId: 'co-abc', limit: 5 }, client);
56
+
57
+ expect(client.get).toHaveBeenCalledWith('/api/v1/share-classes', {
58
+ params: { companyId: 'co-abc', limit: 5 },
59
+ });
60
+ });
61
+ });
62
+
63
+ describe('get_share_class', () => {
64
+ it('calls GET /api/v1/share-classes/:id', async () => {
65
+ const client = makeClient({
66
+ get: jest.fn().mockResolvedValue({
67
+ data: { id: 'sc-99', name: 'Series B Preferred' },
68
+ }),
69
+ });
70
+
71
+ const result = await getTool.handler({ id: 'sc-99' }, client);
72
+
73
+ expect(client.get).toHaveBeenCalledWith('/api/v1/share-classes/sc-99');
74
+ expect(firstText(result)).toContain('Series B Preferred');
75
+ });
76
+ });
77
+
78
+ describe('create_share_class', () => {
79
+ const validInput = {
80
+ name: 'Series C Preferred',
81
+ classType: 'preferred' as const,
82
+ authorizedShares: 10_000_000,
83
+ companyId: 'co-1',
84
+ };
85
+
86
+ it('posts to /api/v1/share-classes with the correct body', async () => {
87
+ const client = makeClient({
88
+ post: jest.fn().mockResolvedValue({ data: { id: 'sc-new', ...validInput } }),
89
+ });
90
+
91
+ const result = await createTool.handler(validInput, client);
92
+
93
+ expect(client.post).toHaveBeenCalledWith('/api/v1/share-classes', validInput);
94
+ expect(firstText(result)).toContain('Share class created');
95
+ expect(firstText(result)).toContain('Series C Preferred');
96
+ });
97
+
98
+ it('validates that authorizedShares must be a positive integer', () => {
99
+ const parsed = createTool.inputSchema.safeParse({
100
+ ...validInput,
101
+ authorizedShares: -100,
102
+ });
103
+ expect(parsed.success).toBe(false);
104
+ });
105
+
106
+ it('validates that classType must be one of the allowed values', () => {
107
+ const parsed = createTool.inputSchema.safeParse({
108
+ ...validInput,
109
+ classType: 'convertible_note',
110
+ });
111
+ expect(parsed.success).toBe(false);
112
+ });
113
+
114
+ it('accepts optional fields like parValue and liquidationPreference', async () => {
115
+ const client = makeClient({
116
+ post: jest.fn().mockResolvedValue({ data: { id: 'sc-opt' } }),
117
+ });
118
+
119
+ const inputWithOptionals = {
120
+ ...validInput,
121
+ parValue: 0.0001,
122
+ liquidationPreference: 1,
123
+ participationRights: 'none' as const,
124
+ };
125
+
126
+ await createTool.handler(inputWithOptionals, client);
127
+
128
+ expect(client.post).toHaveBeenCalledWith(
129
+ '/api/v1/share-classes',
130
+ inputWithOptionals
131
+ );
132
+ });
133
+ });
@@ -0,0 +1,147 @@
1
+ import { stakeholderTools } from '../../src/tools/stakeholders.js';
2
+ import { type AxiosInstance } from 'axios';
3
+ import { type ToolResult } from '../../src/types.js';
4
+
5
+ function makeClient(overrides: Partial<AxiosInstance> = {}): AxiosInstance {
6
+ return {
7
+ get: jest.fn(),
8
+ post: jest.fn(),
9
+ put: jest.fn(),
10
+ delete: jest.fn(),
11
+ ...overrides,
12
+ } as unknown as AxiosInstance;
13
+ }
14
+
15
+ /** Narrow the first content item to a text block and return its text. */
16
+ function firstText(result: ToolResult): string {
17
+ const item = result.content[0];
18
+ if (item.type !== 'text') throw new Error(`Expected text content, got ${item.type}`);
19
+ return item.text;
20
+ }
21
+
22
+ const listTool = stakeholderTools.find((t) => t.name === 'list_stakeholders')!;
23
+ const getTool = stakeholderTools.find((t) => t.name === 'get_stakeholder')!;
24
+ const createTool = stakeholderTools.find((t) => t.name === 'create_stakeholder')!;
25
+ const updateTool = stakeholderTools.find((t) => t.name === 'update_stakeholder')!;
26
+
27
+ describe('list_stakeholders', () => {
28
+ it('calls GET /api/v1/stakeholders', async () => {
29
+ const client = makeClient({
30
+ get: jest.fn().mockResolvedValue({ data: [{ id: '1', name: 'Alice' }] }),
31
+ });
32
+
33
+ const result = await listTool.handler({}, client);
34
+
35
+ expect(client.get).toHaveBeenCalledWith('/api/v1/stakeholders', { params: {} });
36
+ expect(result.content[0].type).toBe('text');
37
+ expect(firstText(result)).toContain('Alice');
38
+ });
39
+
40
+ it('passes companyId and limit as query params', async () => {
41
+ const client = makeClient({
42
+ get: jest.fn().mockResolvedValue({ data: { stakeholders: [] } }),
43
+ });
44
+
45
+ await listTool.handler({ companyId: 'co-123', limit: 10 }, client);
46
+
47
+ expect(client.get).toHaveBeenCalledWith('/api/v1/stakeholders', {
48
+ params: { companyId: 'co-123', limit: 10 },
49
+ });
50
+ });
51
+
52
+ it('handles the stakeholders envelope', async () => {
53
+ const stakeholders = [{ id: '2', name: 'Bob' }];
54
+ const client = makeClient({
55
+ get: jest.fn().mockResolvedValue({ data: { stakeholders } }),
56
+ });
57
+
58
+ const result = await listTool.handler({}, client);
59
+ expect(firstText(result)).toContain('Bob');
60
+ });
61
+
62
+ it('returns an error content when the API throws', async () => {
63
+ const client = makeClient({
64
+ get: jest.fn().mockRejectedValue(new Error('Network error')),
65
+ });
66
+
67
+ await expect(listTool.handler({}, client)).rejects.toThrow('Network error');
68
+ });
69
+ });
70
+
71
+ describe('get_stakeholder', () => {
72
+ it('calls GET /api/v1/stakeholders/:id', async () => {
73
+ const client = makeClient({
74
+ get: jest.fn().mockResolvedValue({ data: { id: '42', name: 'Carol' } }),
75
+ });
76
+
77
+ const result = await getTool.handler({ id: '42' }, client);
78
+
79
+ expect(client.get).toHaveBeenCalledWith('/api/v1/stakeholders/42');
80
+ expect(firstText(result)).toContain('Carol');
81
+ });
82
+ });
83
+
84
+ describe('create_stakeholder', () => {
85
+ it('posts the correct body to /api/v1/stakeholders', async () => {
86
+ const newStakeholder = {
87
+ id: 'new-1',
88
+ name: 'Dave',
89
+ email: 'dave@example.com',
90
+ role: 'investor',
91
+ companyId: 'co-1',
92
+ };
93
+ const client = makeClient({
94
+ post: jest.fn().mockResolvedValue({ data: newStakeholder }),
95
+ });
96
+
97
+ const input = {
98
+ name: 'Dave',
99
+ email: 'dave@example.com',
100
+ role: 'investor' as const,
101
+ companyId: 'co-1',
102
+ };
103
+ const result = await createTool.handler(input, client);
104
+
105
+ expect(client.post).toHaveBeenCalledWith('/api/v1/stakeholders', input);
106
+ expect(firstText(result)).toContain('Stakeholder created');
107
+ expect(firstText(result)).toContain('Dave');
108
+ });
109
+
110
+ it('validates that email must be a valid email address', () => {
111
+ const parsed = createTool.inputSchema.safeParse({
112
+ name: 'Eve',
113
+ email: 'not-an-email',
114
+ role: 'founder',
115
+ companyId: 'co-1',
116
+ });
117
+ expect(parsed.success).toBe(false);
118
+ });
119
+
120
+ it('validates that role must be one of the allowed values', () => {
121
+ const parsed = createTool.inputSchema.safeParse({
122
+ name: 'Frank',
123
+ email: 'frank@example.com',
124
+ role: 'ceo',
125
+ companyId: 'co-1',
126
+ });
127
+ expect(parsed.success).toBe(false);
128
+ });
129
+ });
130
+
131
+ describe('update_stakeholder', () => {
132
+ it('calls PUT /api/v1/stakeholders/:id with the update body', async () => {
133
+ const client = makeClient({
134
+ put: jest.fn().mockResolvedValue({ data: { id: '99', name: 'Updated Name' } }),
135
+ });
136
+
137
+ const result = await updateTool.handler(
138
+ { id: '99', name: 'Updated Name' },
139
+ client
140
+ );
141
+
142
+ expect(client.put).toHaveBeenCalledWith('/api/v1/stakeholders/99', {
143
+ name: 'Updated Name',
144
+ });
145
+ expect(firstText(result)).toContain('Stakeholder updated');
146
+ });
147
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "declaration": true,
11
+ "declarationMap": true,
12
+ "sourceMap": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist", "tests"]
16
+ }