@critical-path/mcp 0.7.0 → 0.9.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.
@@ -0,0 +1,50 @@
1
+ export interface CliOptions {
2
+ db?: string;
3
+ api?: string;
4
+ headers: string[];
5
+ help?: boolean;
6
+ }
7
+
8
+ export function parseArgs(args: string[]): CliOptions {
9
+ const options: CliOptions = { headers: [] };
10
+ for (let i = 0; i < args.length; i++) {
11
+ const arg = args[i];
12
+ if (arg === '--help' || arg === '-h') {
13
+ options.help = true;
14
+ } else if (arg === '--db' && args[i + 1]) {
15
+ options.db = args[++i];
16
+ } else if (arg === '--api' && args[i + 1]) {
17
+ options.api = args[++i];
18
+ } else if (arg === '--header' && args[i + 1]) {
19
+ options.headers.push(args[++i]);
20
+ }
21
+ }
22
+ return options;
23
+ }
24
+
25
+ /**
26
+ * Builds request headers for `--api` mode from repeated `--header "Name: value"` flags and the
27
+ * `CRITICAL_PATH_API_TOKEN` environment variable (sent as `Authorization: Bearer <token>`).
28
+ * Prefer the environment variable for secrets: command-line flags are visible in process lists.
29
+ */
30
+ export function buildApiHeaders(headerFlags: string[], env: Record<string, string | undefined>): Record<string, string> {
31
+ const headers: Record<string, string> = {};
32
+ if (env.CRITICAL_PATH_API_TOKEN) {
33
+ headers['Authorization'] = `Bearer ${env.CRITICAL_PATH_API_TOKEN}`;
34
+ }
35
+ for (const flag of headerFlags) {
36
+ const separator = flag.indexOf(':');
37
+ if (separator <= 0) {
38
+ throw new Error(`Invalid --header "${flag}". Expected "Name: value".`);
39
+ }
40
+ headers[flag.slice(0, separator).trim()] = flag.slice(separator + 1).trim();
41
+ }
42
+ return headers;
43
+ }
44
+
45
+ /** True when credentials would be sent in cleartext to a host other than this machine. */
46
+ export function isInsecureRemote(apiUrl: string): boolean {
47
+ const url = new URL(apiUrl);
48
+ const localHosts = ['localhost', '127.0.0.1', '[::1]'];
49
+ return url.protocol === 'http:' && !localHosts.includes(url.hostname) && !url.hostname.endsWith('.localhost');
50
+ }
package/src/bin/cli.ts CHANGED
@@ -2,21 +2,7 @@
2
2
  import { CriticalPathEngine, SQLiteStore, InMemoryStore } from '@critical-path/core';
3
3
  import { CriticalPathClient } from '@critical-path/client';
4
4
  import { createCriticalPathMcpServer, startStdioServer } from '../server/index.js';
5
-
6
- function parseArgs(args: string[]) {
7
- const options: { db?: string; api?: string; help?: boolean } = {};
8
- for (let i = 0; i < args.length; i++) {
9
- const arg = args[i];
10
- if (arg === '--help' || arg === '-h') {
11
- options.help = true;
12
- } else if (arg === '--db' && args[i + 1]) {
13
- options.db = args[++i];
14
- } else if (arg === '--api' && args[i + 1]) {
15
- options.api = args[++i];
16
- }
17
- }
18
- return options;
19
- }
5
+ import { parseArgs, buildApiHeaders, isInsecureRemote } from './args.js';
20
6
 
21
7
  function showHelp() {
22
8
  console.log(`
@@ -25,9 +11,13 @@ Critical Path MCP Server (Stdio)
25
11
  Usage:
26
12
  npx @critical-path/mcp [options]
27
13
 
14
+ Environment:
15
+ CRITICAL_PATH_API_TOKEN Sent as "Authorization: Bearer <token>" in --api mode
16
+
28
17
  Options:
29
18
  --db <path> Path to SQLite database file (uses SQLiteStore)
30
19
  --api <url> Base URL of remote Critical Path server (uses CriticalPathClient)
20
+ --header <h> Extra request header for --api, as "Name: value" (repeatable)
31
21
  --help, -h Display this help message
32
22
 
33
23
  Examples:
@@ -37,6 +27,9 @@ Examples:
37
27
  # Run against remote Next.js or SvelteKit route handler
38
28
  npx @critical-path/mcp --api http://localhost:3000/api/critical-path
39
29
 
30
+ # Authenticate against a protected API (token read from the environment)
31
+ CRITICAL_PATH_API_TOKEN=secret npx @critical-path/mcp --api https://app.example.com/api/critical-path
32
+
40
33
  # Run with in-memory store (for testing)
41
34
  npx @critical-path/mcp
42
35
  `);
@@ -54,7 +47,11 @@ async function main() {
54
47
  let server;
55
48
 
56
49
  if (options.api) {
57
- const client = new CriticalPathClient({ baseUrl: options.api });
50
+ const headers = buildApiHeaders(options.headers, process.env);
51
+ if (Object.keys(headers).length > 0 && isInsecureRemote(options.api)) {
52
+ console.error('[Critical Path MCP] Warning: sending credentials over plain http to a remote host.');
53
+ }
54
+ const client = new CriticalPathClient({ baseUrl: options.api, headers });
58
55
  server = createCriticalPathMcpServer({ client, name: 'critical-path-api-mcp' });
59
56
  console.error(`[Critical Path MCP] Connecting to remote API at ${options.api} over stdio...`);
60
57
  } else if (options.db) {
@@ -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
+