From ec12287664aee72e4b058e8c5332470092d65ff2 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Mon, 16 Feb 2026 00:23:38 -0600 Subject: [PATCH 1/5] feat: add compute profile policies and override-aware MCP setup --- src/__tests__/autoBootstrap.test.ts | 40 +- src/__tests__/configHook.test.ts | 9 +- src/__tests__/palantirMcpRescan.test.ts | 84 +++- src/__tests__/palantirMcpSetup.test.ts | 62 ++- src/__tests__/profilePolicy.test.ts | 35 ++ src/__tests__/repoScan.test.ts | 109 +++++ src/docs/snapshot.ts | 4 +- src/index.ts | 8 +- src/palantir-mcp/allowlist.ts | 119 +++--- src/palantir-mcp/commands.ts | 376 ++++++++++++++++-- src/palantir-mcp/opencode-config.ts | 4 +- src/palantir-mcp/profiles/all.ts | 8 + .../profiles/compute-modules-py.ts | 8 + .../profiles/compute-modules-ts.ts | 8 + src/palantir-mcp/profiles/compute-modules.ts | 8 + src/palantir-mcp/profiles/index.ts | 25 ++ .../profiles/osdk-functions-ts.ts | 8 + .../profiles/pipelines-transforms.ts | 8 + src/palantir-mcp/profiles/policy-types.ts | 16 + src/palantir-mcp/profiles/shared.ts | 30 ++ src/palantir-mcp/profiles/unknown.ts | 8 + src/palantir-mcp/repo-scan.ts | 263 +++++++++++- src/palantir-mcp/types.ts | 22 +- 23 files changed, 1131 insertions(+), 131 deletions(-) create mode 100644 src/__tests__/profilePolicy.test.ts create mode 100644 src/__tests__/repoScan.test.ts create mode 100644 src/palantir-mcp/profiles/all.ts create mode 100644 src/palantir-mcp/profiles/compute-modules-py.ts create mode 100644 src/palantir-mcp/profiles/compute-modules-ts.ts create mode 100644 src/palantir-mcp/profiles/compute-modules.ts create mode 100644 src/palantir-mcp/profiles/index.ts create mode 100644 src/palantir-mcp/profiles/osdk-functions-ts.ts create mode 100644 src/palantir-mcp/profiles/pipelines-transforms.ts create mode 100644 src/palantir-mcp/profiles/policy-types.ts create mode 100644 src/palantir-mcp/profiles/shared.ts create mode 100644 src/palantir-mcp/profiles/unknown.ts diff --git a/src/__tests__/autoBootstrap.test.ts b/src/__tests__/autoBootstrap.test.ts index 7fdda18..bc87895 100644 --- a/src/__tests__/autoBootstrap.test.ts +++ b/src/__tests__/autoBootstrap.test.ts @@ -13,6 +13,7 @@ type McpServerConfig = { }; type AgentConfig = { + mode?: string; tools?: Record; }; @@ -78,10 +79,12 @@ describe('autoBootstrapPalantirMcpIfConfigured', () => { expect(cfg.agent?.['foundry-librarian']).toBeTruthy(); expect(cfg.agent?.foundry).toBeTruthy(); + expect(cfg.agent?.['foundry-librarian']?.mode).toBe('subagent'); + expect(cfg.agent?.foundry?.mode).toBe('all'); expect(cfg.agent?.['foundry-librarian']?.tools?.['palantir-mcp_list_datasets']).toBe(true); expect(cfg.agent?.['foundry-librarian']?.tools?.['palantir-mcp_get_dataset']).toBe(true); - expect(cfg.agent?.['foundry-librarian']?.tools?.['palantir-mcp_create_thing']).toBe(false); + expect(cfg.agent?.['foundry-librarian']?.tools?.['palantir-mcp_create_thing']).toBe(true); }); it('is idempotent for repeated runs', async () => { @@ -130,4 +133,39 @@ describe('autoBootstrapPalantirMcpIfConfigured', () => { await autoBootstrapPalantirMcpIfConfigured(tmpDir); expect(spy).not.toHaveBeenCalled(); }); + + it('preserves explicit foundry mode during bootstrap patching', async () => { + process.env.FOUNDRY_TOKEN = 'TEST_TOKEN'; + process.env.FOUNDRY_URL = 'https://example.palantirfoundry.com'; + + vi.spyOn(mcpClient, 'listPalantirMcpTools').mockResolvedValue(['list_datasets']); + + const cfgPath: string = path.join(tmpDir, 'opencode.jsonc'); + const existing: OpencodeConfig = { + mcp: { + 'palantir-mcp': { + type: 'local', + command: [ + 'npx', + '-y', + 'palantir-mcp', + '--foundry-api-url', + 'https://example.palantirfoundry.com', + ], + environment: { FOUNDRY_TOKEN: '{env:FOUNDRY_TOKEN}' }, + }, + }, + agent: { + foundry: { + mode: 'subagent', + }, + }, + }; + fs.writeFileSync(cfgPath, JSON.stringify(existing, null, 2)); + + await autoBootstrapPalantirMcpIfConfigured(tmpDir); + + const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) as OpencodeConfig; + expect(cfg.agent?.foundry?.mode).toBe('subagent'); + }); }); diff --git a/src/__tests__/configHook.test.ts b/src/__tests__/configHook.test.ts index a6a2356..4073f93 100644 --- a/src/__tests__/configHook.test.ts +++ b/src/__tests__/configHook.test.ts @@ -13,7 +13,6 @@ describe('plugin config hook', () => { let tmpDir: string; let priorToken: string | undefined; let priorUrl: string | undefined; - let ensureDocsSpy: ReturnType; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-config-test-')); @@ -22,7 +21,7 @@ describe('plugin config hook', () => { delete process.env.FOUNDRY_TOKEN; delete process.env.FOUNDRY_URL; - ensureDocsSpy = vi.spyOn(snapshotModule, 'ensureDocsParquet').mockResolvedValue({ + vi.spyOn(snapshotModule, 'ensureDocsParquet').mockResolvedValue({ dbPath: path.join(tmpDir, 'data', 'docs.parquet'), changed: false, source: 'existing', @@ -53,7 +52,9 @@ describe('plugin config hook', () => { expect(cfg.agent?.['foundry-librarian']).toBeTruthy(); expect(cfg.agent?.foundry).toBeTruthy(); - expect(ensureDocsSpy).toHaveBeenCalledWith( + expect(cfg.agent?.['foundry-librarian']?.mode).toBe('subagent'); + expect(cfg.agent?.foundry?.mode).toBe('all'); + expect(snapshotModule.ensureDocsParquet).toHaveBeenCalledWith( expect.objectContaining({ dbPath: path.join(tmpDir, 'data', 'docs.parquet'), force: false, @@ -74,6 +75,7 @@ describe('plugin config hook', () => { }, agent: { foundry: { + mode: 'subagent', prompt: 'CUSTOM_PROMPT', tools: { get_doc_page: true, @@ -87,6 +89,7 @@ describe('plugin config hook', () => { expect(cfg.command?.['refresh-docs']?.template).toBe('CUSTOM_TEMPLATE'); expect(cfg.command?.['refresh-docs']?.description).toBe('CUSTOM_DESCRIPTION'); + expect(cfg.agent?.foundry?.mode).toBe('subagent'); expect(cfg.agent?.foundry?.prompt).toBe('CUSTOM_PROMPT'); expect(cfg.agent?.foundry?.tools?.get_doc_page).toBe(true); // Additive defaults are allowed. diff --git a/src/__tests__/palantirMcpRescan.test.ts b/src/__tests__/palantirMcpRescan.test.ts index 9a4e20c..9c2a7cb 100644 --- a/src/__tests__/palantirMcpRescan.test.ts +++ b/src/__tests__/palantirMcpRescan.test.ts @@ -12,7 +12,7 @@ type CommandHookOutput = { parts: unknown[] }; type CommandHook = (input: CommandHookInput, output: CommandHookOutput) => Promise; type OpencodeConfig = { - agent?: Record }>; + agent?: Record }>; }; function isRecord(value: unknown): value is Record { @@ -47,14 +47,14 @@ describe('/rescan-palantir-mcp-tools', () => { else process.env.FOUNDRY_TOKEN = priorToken; }); - async function runRescan(): Promise<{ text: string }> { + async function runRescan(args = ''): Promise<{ text: string }> { const hooks = await plugin({ worktree: tmpDir }); const hook = hooks['command.execute.before']; if (typeof hook !== 'function') throw new Error('Missing command.execute.before hook'); const output: CommandHookOutput = { parts: [] }; await (hook as CommandHook)( - { command: 'rescan-palantir-mcp-tools', sessionID: 'test-session', arguments: '' }, + { command: 'rescan-palantir-mcp-tools', sessionID: 'test-session', arguments: args }, output ); return { text: getFirstTextPart(output) }; @@ -68,6 +68,12 @@ describe('/rescan-palantir-mcp-tools', () => { expect(spy).not.toHaveBeenCalled(); }); + it('returns a validation error for invalid profile override', async () => { + const result = await runRescan('--profile nope'); + expect(result.text).toContain('invalid'); + expect(result.text).toContain('Valid values'); + }); + it('preserves existing palantir-mcp_* toggles and adds missing ones', async () => { vi.spyOn(mcpClient, 'listPalantirMcpTools').mockResolvedValue(['list_datasets', 'get_dataset']); @@ -107,11 +113,83 @@ describe('/rescan-palantir-mcp-tools', () => { 'Generated by opencode-palantir /setup-palantir-mcp.' ); expect(cfg.agent?.foundry?.description).toContain('Profile:'); + expect(cfg.agent?.foundry?.mode).toBe('all'); expect(cfg.agent?.foundry?.tools?.['palantir-mcp_list_datasets']).toBe(false); expect(cfg.agent?.foundry?.tools?.['palantir-mcp_get_dataset']).toBe(true); }); + it('preserves explicit foundry mode during rescan', async () => { + vi.spyOn(mcpClient, 'listPalantirMcpTools').mockResolvedValue(['list_datasets']); + + const cfgPath = path.join(tmpDir, 'opencode.jsonc'); + const seeded = { + mcp: { + 'palantir-mcp': { + type: 'local', + command: [ + 'npx', + '-y', + 'palantir-mcp', + '--foundry-api-url', + 'https://example.palantirfoundry.com', + ], + environment: { FOUNDRY_TOKEN: '{env:FOUNDRY_TOKEN}' }, + }, + }, + agent: { + foundry: { + mode: 'subagent', + }, + }, + }; + fs.writeFileSync(cfgPath, JSON.stringify(seeded, null, 2)); + + await runRescan(); + + const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) as OpencodeConfig; + expect(cfg.agent?.foundry?.mode).toBe('subagent'); + }); + + it('applies profile override and reports detected profile in output', async () => { + vi.spyOn(mcpClient, 'listPalantirMcpTools').mockResolvedValue([ + 'connect_to_dev_console_app', + 'delete_foundry_object_type', + ]); + + fs.mkdirSync(path.join(tmpDir, 'transforms'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'transforms', 't.py'), 'def transform():\n return 1\n'); + + const cfgPath = path.join(tmpDir, 'opencode.jsonc'); + const seeded = { + mcp: { + 'palantir-mcp': { + type: 'local', + command: [ + 'npx', + '-y', + 'palantir-mcp', + '--foundry-api-url', + 'https://example.palantirfoundry.com', + ], + environment: { FOUNDRY_TOKEN: '{env:FOUNDRY_TOKEN}' }, + }, + }, + tools: { 'palantir-mcp_*': false }, + agent: {}, + }; + fs.writeFileSync(cfgPath, JSON.stringify(seeded, null, 2)); + + const result = await runRescan('--profile compute_modules'); + expect(result.text).toContain('Selected profile: compute_modules'); + expect(result.text).toContain('Profile source: override (--profile)'); + expect(result.text).toContain('Detected profile: pipelines_transforms'); + + const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) as OpencodeConfig; + expect(cfg.agent?.foundry?.tools?.['palantir-mcp_connect_to_dev_console_app']).toBe(true); + expect(cfg.agent?.foundry?.tools?.['palantir-mcp_delete_foundry_object_type']).toBe(false); + }); + it('fails safely on invalid jsonc', async () => { vi.spyOn(mcpClient, 'listPalantirMcpTools').mockResolvedValue(['list_datasets']); diff --git a/src/__tests__/palantirMcpSetup.test.ts b/src/__tests__/palantirMcpSetup.test.ts index 6e9082d..3144eff 100644 --- a/src/__tests__/palantirMcpSetup.test.ts +++ b/src/__tests__/palantirMcpSetup.test.ts @@ -19,6 +19,7 @@ type McpServerConfig = { type AgentConfig = { description?: string; + mode?: string; tools?: Record; }; @@ -88,6 +89,14 @@ describe('/setup-palantir-mcp', () => { expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false); }); + it('returns a validation error for an invalid profile override', async () => { + const result = await runSetup('https://example.palantirfoundry.com --profile nope'); + + expect(result.text).toContain('invalid'); + expect(result.text).toContain('Valid values'); + expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false); + }); + it('normalizes URL and writes mcp server config', async () => { vi.spyOn(mcpClient, 'listPalantirMcpTools').mockResolvedValue(['list_datasets']); @@ -190,18 +199,67 @@ describe('/setup-palantir-mcp', () => { expect(cfg.agent?.['foundry-librarian']?.tools?.get_doc_page).toBe(true); expect(cfg.agent?.['foundry-librarian']?.tools?.list_all_docs).toBe(true); + expect(cfg.agent?.['foundry-librarian']?.mode).toBe('subagent'); // execution agent defaults to no docs tools expect(cfg.agent?.foundry?.tools?.get_doc_page).toBe(false); expect(cfg.agent?.foundry?.tools?.list_all_docs).toBe(false); + expect(cfg.agent?.foundry?.mode).toBe('all'); expect(cfg.agent?.['foundry-librarian']?.tools?.['palantir-mcp_list_datasets']).toBe(true); expect(cfg.agent?.['foundry-librarian']?.tools?.['palantir-mcp_get_dataset']).toBe(true); - expect(cfg.agent?.['foundry-librarian']?.tools?.['palantir-mcp_create_thing']).toBe(false); + expect(cfg.agent?.['foundry-librarian']?.tools?.['palantir-mcp_create_thing']).toBe(true); expect(cfg.agent?.foundry?.tools?.['palantir-mcp_list_datasets']).toBe(true); expect(cfg.agent?.foundry?.tools?.['palantir-mcp_get_dataset']).toBe(true); - expect(cfg.agent?.foundry?.tools?.['palantir-mcp_create_thing']).toBe(false); + expect(cfg.agent?.foundry?.tools?.['palantir-mcp_create_thing']).toBe(true); + }); + + it('preserves explicit foundry mode when already set', async () => { + vi.spyOn(mcpClient, 'listPalantirMcpTools').mockResolvedValue(['list_datasets']); + + const cfgPath = path.join(tmpDir, 'opencode.jsonc'); + const existing = { + agent: { + foundry: { + mode: 'subagent', + }, + }, + }; + fs.writeFileSync(cfgPath, JSON.stringify(existing, null, 2)); + + await runSetup('https://example.palantirfoundry.com'); + const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) as OpencodeConfig; + + expect(cfg.agent?.foundry?.mode).toBe('subagent'); + }); + + it('uses profile override when provided and reports detected profile', async () => { + vi.spyOn(mcpClient, 'listPalantirMcpTools').mockResolvedValue([ + 'connect_to_dev_console_app', + 'delete_foundry_object_type', + ]); + + fs.mkdirSync(path.join(tmpDir, 'transforms'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'transforms', 't.py'), 'def transform():\n return 1\n'); + + const result = await runSetup( + 'https://example.palantirfoundry.com --profile compute_modules_ts' + ); + + expect(result.text).toContain('Selected profile: compute_modules_ts'); + expect(result.text).toContain('Profile source: override (--profile)'); + expect(result.text).toContain('Detected profile: pipelines_transforms'); + + const cfg = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'opencode.jsonc'), 'utf8') + ) as OpencodeConfig; + expect(cfg.agent?.foundry?.description).toContain('Profile: compute_modules_ts'); + + // Broad defaults keep compute click-ops enabled by default. + expect(cfg.agent?.foundry?.tools?.['palantir-mcp_connect_to_dev_console_app']).toBe(true); + // Hard destructive deny list still applies. + expect(cfg.agent?.foundry?.tools?.['palantir-mcp_delete_foundry_object_type']).toBe(false); }); it('is idempotent for repeated runs', async () => { diff --git a/src/__tests__/profilePolicy.test.ts b/src/__tests__/profilePolicy.test.ts new file mode 100644 index 0000000..a1f8d1e --- /dev/null +++ b/src/__tests__/profilePolicy.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { computeAllowedTools } from '../palantir-mcp/allowlist.ts'; + +describe('profile policy allowlists', () => { + it('uses broad defaults for compute profiles and only denies hard-destructive tools', () => { + const allowlist = computeAllowedTools('compute_modules', [ + 'connect_to_dev_console_app', + 'create_foundry_branch', + 'delete_foundry_object_type', + ]); + + expect(allowlist.policy.id).toBe('compute_modules'); + expect(allowlist.policy.librarianDefaultAllow).toBe('all'); + expect(allowlist.policy.foundryDefaultAllow).toBe('all'); + + expect(allowlist.librarianAllow.has('connect_to_dev_console_app')).toBe(true); + expect(allowlist.foundryAllow.has('connect_to_dev_console_app')).toBe(true); + + expect(allowlist.librarianAllow.has('create_foundry_branch')).toBe(true); + expect(allowlist.foundryAllow.has('create_foundry_branch')).toBe(true); + + expect(allowlist.librarianAllow.has('delete_foundry_object_type')).toBe(false); + expect(allowlist.foundryAllow.has('delete_foundry_object_type')).toBe(false); + expect(allowlist.policy.deniedTools).toContain('delete_foundry_object_type'); + }); + + it('keeps unknown profile broad by default to optimize usability', () => { + const allowlist = computeAllowedTools('unknown', ['create_foundry_branch']); + + expect(allowlist.policy.id).toBe('unknown'); + expect(allowlist.librarianAllow.has('create_foundry_branch')).toBe(true); + expect(allowlist.foundryAllow.has('create_foundry_branch')).toBe(true); + }); +}); diff --git a/src/__tests__/repoScan.test.ts b/src/__tests__/repoScan.test.ts new file mode 100644 index 0000000..ecef20f --- /dev/null +++ b/src/__tests__/repoScan.test.ts @@ -0,0 +1,109 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { scanRepoForProfile } from '../palantir-mcp/repo-scan.ts'; + +const tmpDirs: string[] = []; + +function makeTmpDir(): string { + const dir: string = fs.mkdtempSync(path.join(os.tmpdir(), 'profile-scan-test-')); + tmpDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tmpDirs.splice(0, tmpDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('scanRepoForProfile', () => { + it('detects compute_modules_ts for TypeScript compute module repos', async () => { + const root: string = makeTmpDir(); + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify( + { + dependencies: { + '@palantir/compute-module-sdk': '^1.0.0', + typescript: '^5.0.0', + }, + }, + null, + 2 + ) + ); + fs.mkdirSync(path.join(root, 'src', 'compute-modules'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'src', 'compute-modules', 'index.ts'), + 'export const runComputeModule = () => "compute module";\n' + ); + + const scan = await scanRepoForProfile(root); + expect(scan.profile).toBe('compute_modules_ts'); + expect(scan.reasons.join('\n')).toContain('compute-module dependency'); + }); + + it('detects compute_modules_py for Python compute module repos', async () => { + const root: string = makeTmpDir(); + fs.writeFileSync( + path.join(root, 'pyproject.toml'), + [ + '[project]', + 'name = "compute-modules-python"', + 'dependencies = ["palantir-foundry-compute-modules"]', + ].join('\n') + ); + fs.mkdirSync(path.join(root, 'compute_modules'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'compute_modules', 'main.py'), + 'def run_compute_module() -> str:\n return "ok"\n' + ); + + const scan = await scanRepoForProfile(root); + expect(scan.profile).toBe('compute_modules_py'); + expect(scan.reasons.join('\n')).toContain('pyproject.toml mentions compute modules'); + }); + + it('detects compute_modules for mixed TypeScript and Python signals', async () => { + const root: string = makeTmpDir(); + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify( + { + dependencies: { + '@palantir/compute-module-sdk': '^1.0.0', + typescript: '^5.0.0', + }, + }, + null, + 2 + ) + ); + fs.writeFileSync( + path.join(root, 'pyproject.toml'), + ['[project]', 'name = "hybrid"', 'dependencies = ["foundry-compute-modules"]'].join('\n') + ); + fs.writeFileSync( + path.join(root, 'tsconfig.json'), + JSON.stringify({ compilerOptions: {} }, null, 2) + ); + + const scan = await scanRepoForProfile(root); + expect(scan.profile).toBe('compute_modules'); + expect(scan.reasons.join('\n')).toContain( + 'Detected both TypeScript and Python compute-module signals' + ); + }); + + it('falls back to unknown when confidence is low', async () => { + const root: string = makeTmpDir(); + fs.writeFileSync(path.join(root, 'README.md'), '# hello\n'); + + const scan = await scanRepoForProfile(root); + expect(scan.profile).toBe('unknown'); + }); +}); diff --git a/src/docs/snapshot.ts b/src/docs/snapshot.ts index 73fc8ee..96c937d 100644 --- a/src/docs/snapshot.ts +++ b/src/docs/snapshot.ts @@ -1,6 +1,8 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import type { Stats } from 'node:fs'; + export const DEFAULT_DOCS_SNAPSHOT_URLS: string[] = [ 'https://raw.githubusercontent.com/anand-testcompare/opencode-palantir/main/data/docs.parquet', ]; @@ -67,7 +69,7 @@ async function ensureDirectoryExists(dbPath: string): Promise { await fs.mkdir(path.dirname(dbPath), { recursive: true }); } -async function statIfExists(filePath: string): Promise { +async function statIfExists(filePath: string): Promise { try { return await fs.stat(filePath); } catch (err) { diff --git a/src/index.ts b/src/index.ts index 906a621..b800368 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,7 +69,7 @@ const plugin: Plugin = async (input) => { cfg.command['setup-palantir-mcp'] = { template: 'Set up palantir-mcp for this repo.', description: - 'Guided MCP setup for Foundry. Usage: /setup-palantir-mcp . Requires FOUNDRY_TOKEN for tool discovery.', + 'Guided MCP setup for Foundry. Usage: /setup-palantir-mcp [--profile ]. Requires FOUNDRY_TOKEN for tool discovery.', }; } @@ -77,7 +77,7 @@ const plugin: Plugin = async (input) => { cfg.command['rescan-palantir-mcp-tools'] = { template: 'Re-scan palantir-mcp tools and patch tool gating.', description: - 'Re-discovers the palantir-mcp tool list and adds missing palantir-mcp_* toggles (does not overwrite existing toggles). Requires FOUNDRY_TOKEN.', + 'Re-discovers the palantir-mcp tool list and adds missing palantir-mcp_* toggles (does not overwrite existing toggles). Usage: /rescan-palantir-mcp-tools [--profile ]. Requires FOUNDRY_TOKEN.', }; } } @@ -92,7 +92,7 @@ const plugin: Plugin = async (input) => { : 'Foundry execution agent (uses only enabled palantir-mcp tools)'; if (agent.mode !== 'subagent' && agent.mode !== 'primary' && agent.mode !== 'all') { - agent.mode = 'subagent'; + agent.mode = agentName === 'foundry' ? 'all' : 'subagent'; } if (typeof agent['hidden'] !== 'boolean') agent['hidden'] = false; @@ -691,7 +691,7 @@ const plugin: Plugin = async (input) => { } if (hookInput.command === 'rescan-palantir-mcp-tools') { - const text = await rescanPalantirMcpTools(input.worktree); + const text = await rescanPalantirMcpTools(input.worktree, hookInput.arguments ?? ''); pushText(output, text); return; } diff --git a/src/palantir-mcp/allowlist.ts b/src/palantir-mcp/allowlist.ts index 4c5fd5d..7331954 100644 --- a/src/palantir-mcp/allowlist.ts +++ b/src/palantir-mcp/allowlist.ts @@ -1,82 +1,87 @@ +import { getProfilePolicy, type AgentToolPolicy } from './profiles/index.ts'; import type { ProfileId } from './types.ts'; export type ComputedAllowlist = { librarianAllow: ReadonlySet; foundryAllow: ReadonlySet; + policy: { + id: ProfileId; + title: string; + description: string; + librarianDefaultAllow: AgentToolPolicy['defaultAllow']; + foundryDefaultAllow: AgentToolPolicy['defaultAllow']; + deniedTools: string[]; + }; }; -function isMutatingTool(toolName: string): boolean { - // Conservative. Err on the side of disabling anything that sounds like it changes state. - const re: RegExp = - /(?:^|[_-])(create|update|delete|remove|set|write|put|post|patch|deploy|publish|commit|run|execute|trigger|start|stop|cancel|schedule|grant|revoke|upload|import|export)(?:$|[_-])/i; - return re.test(toolName); -} - function isReadOnlyTool(toolName: string): boolean { const re: RegExp = - /(?:^|[_-])(get|list|search|query|describe|read|fetch|inspect|schema|metadata|lineage|preview|validate|diff)(?:$|[_-])/i; + /(?:^|[_-])(get|list|search|query|describe|read|fetch|inspect|schema|metadata|lineage|preview|validate|diff|view)(?:$|[_-])/i; return re.test(toolName); } -function matchesAny(toolName: string, patterns: RegExp[]): boolean { +function matchesAny(toolName: string, patterns: readonly RegExp[] | undefined): boolean { + if (!patterns || patterns.length === 0) return false; return patterns.some((p) => p.test(toolName)); } -export function computeAllowedTools(profile: ProfileId, toolNames: string[]): ComputedAllowlist { - const uniqueSortedTools: string[] = Array.from(new Set(toolNames)).sort((a, b) => - a.localeCompare(b) - ); - - const librarianAllow: Set = new Set(); - - const pipelinesBoost: RegExp[] = [ - /pipeline/i, - /transform/i, - /job/i, - /dataset/i, - /ontology/i, - /object/i, - /action/i, - /lineage/i, - /schema/i, - /preview/i, - ]; - - const osdkBoost: RegExp[] = [ - /osdk/i, - /function/i, - /artifact/i, - /package/i, - /release/i, - /deploy/i, - ]; - - for (const name of uniqueSortedTools) { - if (isMutatingTool(name)) continue; +function matchesExact(toolName: string, names: readonly string[] | undefined): boolean { + if (!names || names.length === 0) return false; + return names.includes(toolName); +} - if (profile === 'all') { - librarianAllow.add(name); - continue; - } +function evaluateAgentPolicy( + toolNames: string[], + policy: AgentToolPolicy +): { + allow: Set; + deniedByPolicy: Set; +} { + const allow: Set = new Set(); + const deniedByPolicy: Set = new Set(); - if (isReadOnlyTool(name)) { - librarianAllow.add(name); - continue; + for (const name of toolNames) { + let enabled: boolean = policy.defaultAllow === 'all' || isReadOnlyTool(name); + if (!enabled && matchesAny(name, policy.allowPatterns)) { + enabled = true; } - if (profile === 'pipelines_transforms' && matchesAny(name, pipelinesBoost)) { - librarianAllow.add(name); - continue; + const denied: boolean = + matchesExact(name, policy.denyTools) || matchesAny(name, policy.denyPatterns); + if (denied) { + enabled = false; + deniedByPolicy.add(name); } - if (profile === 'osdk_functions_ts' && matchesAny(name, osdkBoost)) { - librarianAllow.add(name); - continue; - } + if (enabled) allow.add(name); } - // v1: keep foundry agent conservative as well; it can be expanded later. - const foundryAllow: Set = new Set(librarianAllow); + return { allow, deniedByPolicy }; +} + +export function computeAllowedTools(profile: ProfileId, toolNames: string[]): ComputedAllowlist { + const uniqueSortedTools: string[] = Array.from(new Set(toolNames)).sort((a, b) => + a.localeCompare(b) + ); + const policy = getProfilePolicy(profile); + + const librarianEval = evaluateAgentPolicy(uniqueSortedTools, policy.librarian); + const foundryEval = evaluateAgentPolicy(uniqueSortedTools, policy.foundry); + + const deniedTools: string[] = Array.from( + new Set([...librarianEval.deniedByPolicy, ...foundryEval.deniedByPolicy]) + ).sort((a, b) => a.localeCompare(b)); - return { librarianAllow, foundryAllow }; + return { + librarianAllow: librarianEval.allow, + foundryAllow: foundryEval.allow, + policy: { + id: policy.id, + title: policy.title, + description: policy.description, + librarianDefaultAllow: policy.librarian.defaultAllow, + foundryDefaultAllow: policy.foundry.defaultAllow, + deniedTools, + }, + }; } diff --git a/src/palantir-mcp/commands.ts b/src/palantir-mcp/commands.ts index 1bef529..8cb59c1 100644 --- a/src/palantir-mcp/commands.ts +++ b/src/palantir-mcp/commands.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { computeAllowedTools } from './allowlist.ts'; +import { computeAllowedTools, type ComputedAllowlist } from './allowlist.ts'; import { listPalantirMcpTools } from './mcp-client.ts'; import { normalizeFoundryBaseUrl } from './normalize-url.ts'; import { @@ -17,7 +17,39 @@ import { type PatchResult, } from './opencode-config.ts'; import { scanRepoForProfile } from './repo-scan.ts'; -import type { ProfileId } from './types.ts'; +import { PROFILE_IDS, parseProfileId, type ProfileId } from './types.ts'; + +const PROFILE_OVERRIDE_ENV_KEYS: readonly string[] = [ + 'PALANTIR_MCP_PROFILE', + 'OPENCODE_PALANTIR_PROFILE', +]; + +type ParsedSetupArgs = + | { ok: true; foundryUrlArg: string; profileOverrideArg: string | null } + | { ok: false; error: string }; + +type ParsedRescanArgs = + | { ok: true; profileOverrideArg: string | null } + | { ok: false; error: string }; + +type ProfileOverride = { + profile: ProfileId; + sourceLabel: string; +}; + +type ProfileOverrideResolution = { + override: ProfileOverride | null; + warnings: string[]; + error: string | null; +}; + +type ProfileResolution = { + profile: ProfileId; + profileSource: 'detected' | 'override'; + detectedProfile: ProfileId; + detectionReasons: string[]; + override: ProfileOverride | null; +}; function formatError(err: unknown): string { return err instanceof Error ? err.toString() : String(err); @@ -47,10 +79,232 @@ function formatWarnings(warnings: string[]): string { return `\n\nWarnings:\n${warnings.map((w) => `- ${w}`).join('\n')}`; } -function formatPatchSummary(patch: PatchResult): string { +function formatProfileChoices(): string { + return PROFILE_IDS.join(', '); +} + +function setupUsageText(): string { + return [ + 'Usage:', + ' /setup-palantir-mcp [--profile ]', + '', + 'Or set:', + ' export FOUNDRY_URL=', + '', + `Valid profile IDs: ${formatProfileChoices()}`, + '', + 'Example:', + ' /setup-palantir-mcp https://23dimethyl.usw-3.palantirfoundry.com --profile compute_modules_ts', + ].join('\n'); +} + +function rescanUsageText(): string { + return [ + 'Usage:', + ' /rescan-palantir-mcp-tools [--profile ]', + '', + `Valid profile IDs: ${formatProfileChoices()}`, + '', + 'Example:', + ' /rescan-palantir-mcp-tools --profile compute_modules', + ].join('\n'); +} + +function tokenizeArgs(rawArgs: string): string[] { + return rawArgs + .trim() + .split(/\s+/g) + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +function parseSetupArgs(rawArgs: string): ParsedSetupArgs { + const tokens: string[] = tokenizeArgs(rawArgs); + let foundryUrlArg: string = ''; + let profileOverrideArg: string | null = null; + + for (let i = 0; i < tokens.length; i += 1) { + const token: string = tokens[i]; + if (token === '--profile') { + const next: string | undefined = tokens[i + 1]; + if (!next || next.startsWith('--')) { + return { ok: false, error: 'Missing value for --profile.' }; + } + profileOverrideArg = next; + i += 1; + continue; + } + + if (token.startsWith('--profile=')) { + const value: string = token.slice('--profile='.length).trim(); + if (!value) return { ok: false, error: 'Missing value for --profile.' }; + profileOverrideArg = value; + continue; + } + + if (token.startsWith('--')) { + return { ok: false, error: `Unknown option: ${token}` }; + } + + if (foundryUrlArg) { + return { + ok: false, + error: + 'Unexpected extra positional argument. Expected only one Foundry URL positional argument.', + }; + } + foundryUrlArg = token; + } + + return { ok: true, foundryUrlArg, profileOverrideArg }; +} + +function parseRescanArgs(rawArgs: string): ParsedRescanArgs { + const tokens: string[] = tokenizeArgs(rawArgs); + let profileOverrideArg: string | null = null; + + for (let i = 0; i < tokens.length; i += 1) { + const token: string = tokens[i]; + if (token === '--profile') { + const next: string | undefined = tokens[i + 1]; + if (!next || next.startsWith('--')) { + return { ok: false, error: 'Missing value for --profile.' }; + } + profileOverrideArg = next; + i += 1; + continue; + } + + if (token.startsWith('--profile=')) { + const value: string = token.slice('--profile='.length).trim(); + if (!value) return { ok: false, error: 'Missing value for --profile.' }; + profileOverrideArg = value; + continue; + } + + if (token.startsWith('--')) { + return { ok: false, error: `Unknown option: ${token}` }; + } + + return { + ok: false, + error: `Unexpected positional argument: ${token}. This command only accepts --profile.`, + }; + } + + return { ok: true, profileOverrideArg }; +} + +function parseProfileFromRaw( + raw: string, + sourceLabel: string +): { profile: ProfileId | null; error: string | null } { + const parsed: ProfileId | null = parseProfileId(raw); + if (!parsed) { + return { + profile: null, + error: `${sourceLabel} profile "${raw}" is invalid. Valid values: ${formatProfileChoices()}.`, + }; + } + return { profile: parsed, error: null }; +} + +function firstEnvProfileOverrideRaw(): { raw: string | null; key: string | null } { + for (const key of PROFILE_OVERRIDE_ENV_KEYS) { + const raw: string | undefined = process.env[key]; + if (typeof raw !== 'string') continue; + const trimmed: string = raw.trim(); + if (!trimmed) continue; + return { raw: trimmed, key }; + } + return { raw: null, key: null }; +} + +function resolveProfileOverride( + profileOverrideArg: string | null, + opts: { strictEnvValidation: boolean } +): ProfileOverrideResolution { + const warnings: string[] = []; + const envOverride = firstEnvProfileOverrideRaw(); + + if (profileOverrideArg) { + const parsedArg = parseProfileFromRaw(profileOverrideArg, '--profile'); + if (parsedArg.error || !parsedArg.profile) { + return { override: null, warnings, error: parsedArg.error ?? 'Invalid --profile value.' }; + } + + if (envOverride.raw && envOverride.key) { + const parsedEnv = parseProfileFromRaw(envOverride.raw, envOverride.key); + if (parsedEnv.error) { + warnings.push( + `${envOverride.key}="${envOverride.raw}" is invalid and was ignored because --profile was provided.` + ); + } else if (parsedEnv.profile !== parsedArg.profile) { + warnings.push( + `--profile ${parsedArg.profile} overrides ${envOverride.key}=${parsedEnv.profile}.` + ); + } + } + + return { + override: { profile: parsedArg.profile, sourceLabel: '--profile' }, + warnings, + error: null, + }; + } + + if (!envOverride.raw || !envOverride.key) { + return { override: null, warnings, error: null }; + } + + const parsedEnv = parseProfileFromRaw(envOverride.raw, envOverride.key); + if (parsedEnv.error || !parsedEnv.profile) { + if (opts.strictEnvValidation) { + return { override: null, warnings, error: parsedEnv.error ?? 'Invalid environment profile.' }; + } + + warnings.push(`${envOverride.key}="${envOverride.raw}" is invalid and was ignored.`); + return { override: null, warnings, error: null }; + } + + return { + override: { profile: parsedEnv.profile, sourceLabel: envOverride.key }, + warnings, + error: null, + }; +} + +function formatPatchSummary( + patch: PatchResult, + profileResolution: ProfileResolution, + allowlist: ComputedAllowlist +): string { const s = patch.summary; const lines: string[] = []; - lines.push(`Profile: ${s.profile}`); + + lines.push(`Selected profile: ${profileResolution.profile}`); + if (profileResolution.profileSource === 'override' && profileResolution.override) { + lines.push(`Profile source: override (${profileResolution.override.sourceLabel})`); + lines.push(`Detected profile: ${profileResolution.detectedProfile}`); + } else { + lines.push('Profile source: detected'); + } + + if (profileResolution.detectionReasons.length > 0) { + lines.push(`Profile signals: ${profileResolution.detectionReasons.slice(0, 3).join('; ')}`); + } + + lines.push(`Policy: ${allowlist.policy.title} (${allowlist.policy.id})`); + lines.push( + `Policy defaults: foundry-librarian=${allowlist.policy.librarianDefaultAllow}, foundry=${allowlist.policy.foundryDefaultAllow}` + ); + lines.push(`Policy-denied tools: ${allowlist.policy.deniedTools.length}/${s.toolCount}`); + if (allowlist.policy.deniedTools.length > 0) { + const preview: string = allowlist.policy.deniedTools.slice(0, 6).join(', '); + const suffix: string = allowlist.policy.deniedTools.length > 6 ? ', ...' : ''; + lines.push(`Policy deny preview: ${preview}${suffix}`); + } + lines.push(`Discovered palantir-mcp tools: ${s.toolCount}`); lines.push(`Enabled (foundry-librarian): ${s.librarianEnabled}`); lines.push(`Enabled (foundry): ${s.foundryEnabled}`); @@ -59,22 +313,43 @@ function formatPatchSummary(patch: PatchResult): string { 'Note: existing palantir-mcp_* tool toggles were preserved; delete them under the Foundry agents to fully regenerate.' ); } + return lines.join('\n'); } -async function resolveProfile(worktree: string): Promise<{ - profile: ProfileId; - reasons: string[]; -}> { +async function resolveProfile( + worktree: string, + override: ProfileOverride | null +): Promise { + let detectedProfile: ProfileId = 'unknown'; + let detectionReasons: string[] = []; + try { const scan = await scanRepoForProfile(worktree); - return { profile: scan.profile, reasons: scan.reasons }; + detectedProfile = scan.profile; + detectionReasons = scan.reasons; } catch (err) { + detectedProfile = 'unknown'; + detectionReasons = [`Repo scan failed; falling back to unknown: ${formatError(err)}`]; + } + + if (override) { return { - profile: 'unknown', - reasons: [`Repo scan failed; falling back to unknown: ${formatError(err)}`], + profile: override.profile, + profileSource: 'override', + detectedProfile, + detectionReasons, + override, }; } + + return { + profile: detectedProfile, + profileSource: 'detected', + detectedProfile, + detectionReasons, + override: null, + }; } function hasPalantirToolToggles( @@ -131,18 +406,19 @@ export async function autoBootstrapPalantirMcpIfConfigured(worktree: string): Pr ? normalizeFoundryBaseUrl(existingMcpUrlRaw) : null; - const { profile } = await resolveProfile(worktree); + const overrideResolution = resolveProfileOverride(null, { strictEnvValidation: false }); + const profileResolution = await resolveProfile(worktree, overrideResolution.override); const discoveryUrl: string = existingMcpUrlNorm && 'url' in existingMcpUrlNorm ? existingMcpUrlNorm.url : normalized.url; const toolNames: string[] = await listPalantirMcpTools(discoveryUrl); if (toolNames.length === 0) return; - const allowlist = computeAllowedTools(profile, toolNames); + const allowlist = computeAllowedTools(profileResolution.profile, toolNames); const patch = patchConfigForSetup(merged, { foundryApiUrl: normalized.url, toolNames, - profile, + profile: profileResolution.profile, allowlist, }); @@ -170,23 +446,23 @@ export async function autoBootstrapPalantirMcpIfConfigured(worktree: string): Pr } export async function setupPalantirMcp(worktree: string, rawArgs: string): Promise { - const urlFromArgs: string = rawArgs.trim(); + const parsedArgs = parseSetupArgs(rawArgs); + if (!parsedArgs.ok) { + return [`[ERROR] ${parsedArgs.error}`, '', setupUsageText()].join('\n'); + } + + const overrideResolution = resolveProfileOverride(parsedArgs.profileOverrideArg, { + strictEnvValidation: true, + }); + if (overrideResolution.error) { + return `[ERROR] ${overrideResolution.error}`; + } + const urlFromEnvRaw: string | undefined = process.env.FOUNDRY_URL; const urlFromEnv: string = typeof urlFromEnvRaw === 'string' ? urlFromEnvRaw.trim() : ''; - const urlArg: string = urlFromArgs || urlFromEnv; + const urlArg: string = parsedArgs.foundryUrlArg || urlFromEnv; if (!urlArg) { - return [ - '[ERROR] Missing Foundry base URL.', - '', - 'Usage:', - ' /setup-palantir-mcp ', - '', - 'Or set:', - ' export FOUNDRY_URL=', - '', - 'Example:', - ' /setup-palantir-mcp https://23dimethyl.usw-3.palantirfoundry.com', - ].join('\n'); + return ['[ERROR] Missing Foundry base URL.', '', setupUsageText()].join('\n'); } const normalized = normalizeFoundryBaseUrl(urlArg); @@ -220,7 +496,7 @@ export async function setupPalantirMcp(worktree: string, rawArgs: string): Promi const existingMcpUrlRaw: string | null = extractFoundryApiUrlFromMcpConfig(merged); const existingMcpUrlNorm = existingMcpUrlRaw ? normalizeFoundryBaseUrl(existingMcpUrlRaw) : null; - const { profile } = await resolveProfile(worktree); + const profileResolution = await resolveProfile(worktree, overrideResolution.override); const discoveryUrl: string = existingMcpUrlNorm && 'url' in existingMcpUrlNorm ? existingMcpUrlNorm.url : normalized.url; let toolNames: string[]; @@ -231,11 +507,11 @@ export async function setupPalantirMcp(worktree: string, rawArgs: string): Promi } if (toolNames.length === 0) return '[ERROR] palantir-mcp tool discovery returned no tools.'; - const allowlist = computeAllowedTools(profile, toolNames); + const allowlist = computeAllowedTools(profileResolution.profile, toolNames); const patch = patchConfigForSetup(merged, { foundryApiUrl: normalized.url, toolNames, - profile, + profile: profileResolution.profile, allowlist, }); @@ -258,7 +534,11 @@ export async function setupPalantirMcp(worktree: string, rawArgs: string): Promi } } - const warnings: string[] = [...normalized.warnings, ...patch.warnings]; + const warnings: string[] = [ + ...overrideResolution.warnings, + ...normalized.warnings, + ...patch.warnings, + ]; if ( existingMcpUrlNorm && 'url' in existingMcpUrlNorm && @@ -272,7 +552,7 @@ export async function setupPalantirMcp(worktree: string, rawArgs: string): Promi return [ 'palantir-mcp setup complete.', '', - formatPatchSummary(patch), + formatPatchSummary(patch, profileResolution, allowlist), bakInfo, formatWarnings(warnings), ] @@ -280,7 +560,17 @@ export async function setupPalantirMcp(worktree: string, rawArgs: string): Promi .join('\n'); } -export async function rescanPalantirMcpTools(worktree: string): Promise { +export async function rescanPalantirMcpTools(worktree: string, rawArgs: string): Promise { + const parsedArgs = parseRescanArgs(rawArgs); + if (!parsedArgs.ok) { + return [`[ERROR] ${parsedArgs.error}`, '', rescanUsageText()].join('\n'); + } + + const overrideResolution = resolveProfileOverride(parsedArgs.profileOverrideArg, { + strictEnvValidation: true, + }); + if (overrideResolution.error) return `[ERROR] ${overrideResolution.error}`; + if (!process.env.FOUNDRY_TOKEN) { return [ '[ERROR] FOUNDRY_TOKEN is not set in your environment.', @@ -317,7 +607,7 @@ export async function rescanPalantirMcpTools(worktree: string): Promise const normalized = normalizeFoundryBaseUrl(foundryUrlRaw); if ('error' in normalized) return `[ERROR] Invalid Foundry URL in config: ${normalized.error}`; - const { profile } = await resolveProfile(worktree); + const profileResolution = await resolveProfile(worktree, overrideResolution.override); let toolNames: string[]; try { toolNames = await listPalantirMcpTools(normalized.url); @@ -326,8 +616,12 @@ export async function rescanPalantirMcpTools(worktree: string): Promise } if (toolNames.length === 0) return '[ERROR] palantir-mcp tool discovery returned no tools.'; - const allowlist = computeAllowedTools(profile, toolNames); - const patch = patchConfigForRescan(baseData, { toolNames, profile, allowlist }); + const allowlist = computeAllowedTools(profileResolution.profile, toolNames); + const patch = patchConfigForRescan(baseData, { + toolNames, + profile: profileResolution.profile, + allowlist, + }); const outPath: string = path.join(worktree, OPENCODE_JSONC_FILENAME); const text: string = stringifyJsonc(patch.data); @@ -338,12 +632,16 @@ export async function rescanPalantirMcpTools(worktree: string): Promise return `[ERROR] Failed writing ${OPENCODE_JSONC_FILENAME}: ${formatError(err)}`; } - const warnings: string[] = [...normalized.warnings, ...patch.warnings]; + const warnings: string[] = [ + ...overrideResolution.warnings, + ...normalized.warnings, + ...patch.warnings, + ]; return [ 'palantir-mcp tools rescan complete.', '', - formatPatchSummary(patch), + formatPatchSummary(patch, profileResolution, allowlist), formatWarnings(warnings), ] .filter((x) => x.trim().length > 0) diff --git a/src/palantir-mcp/opencode-config.ts b/src/palantir-mcp/opencode-config.ts index 6bd9660..e2c06b4 100644 --- a/src/palantir-mcp/opencode-config.ts +++ b/src/palantir-mcp/opencode-config.ts @@ -242,7 +242,9 @@ function ensureAgentDefaults( : 'Foundry execution agent (uses only enabled palantir-mcp tools)'; const mode: unknown = agent['mode']; - if (typeof mode !== 'string') agent['mode'] = 'subagent'; + if (typeof mode !== 'string') { + agent['mode'] = agentName === 'foundry' ? 'all' : 'subagent'; + } if (typeof agent['hidden'] !== 'boolean') agent['hidden'] = false; diff --git a/src/palantir-mcp/profiles/all.ts b/src/palantir-mcp/profiles/all.ts new file mode 100644 index 0000000..e22075e --- /dev/null +++ b/src/palantir-mcp/profiles/all.ts @@ -0,0 +1,8 @@ +import { createBroadProfile } from './shared.ts'; + +export const allProfilePolicy = createBroadProfile( + 'all', + 'Mixed / Monorepo', + 'Broad defaults for mixed repos that combine multiple Foundry surfaces.', + [/compute/i, /pipeline/i, /transform/i, /osdk/i, /ontology/i] +); diff --git a/src/palantir-mcp/profiles/compute-modules-py.ts b/src/palantir-mcp/profiles/compute-modules-py.ts new file mode 100644 index 0000000..517e335 --- /dev/null +++ b/src/palantir-mcp/profiles/compute-modules-py.ts @@ -0,0 +1,8 @@ +import { createBroadProfile } from './shared.ts'; + +export const computeModulesPyPolicy = createBroadProfile( + 'compute_modules_py', + 'Compute Modules (Python)', + 'Broad operational baseline for Python-first compute module repos.', + [/compute/i, /module/i, /python/i, /\bpy\b/i, /dev_console/i, /egress/i] +); diff --git a/src/palantir-mcp/profiles/compute-modules-ts.ts b/src/palantir-mcp/profiles/compute-modules-ts.ts new file mode 100644 index 0000000..f0e299e --- /dev/null +++ b/src/palantir-mcp/profiles/compute-modules-ts.ts @@ -0,0 +1,8 @@ +import { createBroadProfile } from './shared.ts'; + +export const computeModulesTsPolicy = createBroadProfile( + 'compute_modules_ts', + 'Compute Modules (TypeScript)', + 'Broad operational baseline for TypeScript-first compute module repos.', + [/compute/i, /module/i, /typescript/i, /\bts\b/i, /dev_console/i, /egress/i] +); diff --git a/src/palantir-mcp/profiles/compute-modules.ts b/src/palantir-mcp/profiles/compute-modules.ts new file mode 100644 index 0000000..c0ae12a --- /dev/null +++ b/src/palantir-mcp/profiles/compute-modules.ts @@ -0,0 +1,8 @@ +import { createBroadProfile } from './shared.ts'; + +export const computeModulesPolicy = createBroadProfile( + 'compute_modules', + 'Compute Modules', + 'Broad operational baseline for Foundry compute module repos.', + [/compute/i, /module/i, /dev_console/i, /egress/i] +); diff --git a/src/palantir-mcp/profiles/index.ts b/src/palantir-mcp/profiles/index.ts new file mode 100644 index 0000000..1f69448 --- /dev/null +++ b/src/palantir-mcp/profiles/index.ts @@ -0,0 +1,25 @@ +import type { ProfileId } from '../types.ts'; +import { allProfilePolicy } from './all.ts'; +import { computeModulesPolicy } from './compute-modules.ts'; +import { computeModulesPyPolicy } from './compute-modules-py.ts'; +import { computeModulesTsPolicy } from './compute-modules-ts.ts'; +import { osdkFunctionsTsPolicy } from './osdk-functions-ts.ts'; +import type { ProfilePolicy } from './policy-types.ts'; +import { pipelinesTransformsPolicy } from './pipelines-transforms.ts'; +import { unknownProfilePolicy } from './unknown.ts'; + +const POLICIES: Record = { + compute_modules: computeModulesPolicy, + compute_modules_ts: computeModulesTsPolicy, + compute_modules_py: computeModulesPyPolicy, + pipelines_transforms: pipelinesTransformsPolicy, + osdk_functions_ts: osdkFunctionsTsPolicy, + all: allProfilePolicy, + unknown: unknownProfilePolicy, +}; + +export function getProfilePolicy(profile: ProfileId): ProfilePolicy { + return POLICIES[profile] ?? unknownProfilePolicy; +} + +export type { AgentToolPolicy, ProfilePolicy } from './policy-types.ts'; diff --git a/src/palantir-mcp/profiles/osdk-functions-ts.ts b/src/palantir-mcp/profiles/osdk-functions-ts.ts new file mode 100644 index 0000000..adbdb0e --- /dev/null +++ b/src/palantir-mcp/profiles/osdk-functions-ts.ts @@ -0,0 +1,8 @@ +import { createBroadProfile } from './shared.ts'; + +export const osdkFunctionsTsPolicy = createBroadProfile( + 'osdk_functions_ts', + 'OSDK Functions (TypeScript)', + 'Broad defaults for OSDK and TypeScript function workflows.', + [/osdk/i, /function/i, /sdk/i, /typescript/i, /\bts\b/i, /artifact/i] +); diff --git a/src/palantir-mcp/profiles/pipelines-transforms.ts b/src/palantir-mcp/profiles/pipelines-transforms.ts new file mode 100644 index 0000000..4e342da --- /dev/null +++ b/src/palantir-mcp/profiles/pipelines-transforms.ts @@ -0,0 +1,8 @@ +import { createBroadProfile } from './shared.ts'; + +export const pipelinesTransformsPolicy = createBroadProfile( + 'pipelines_transforms', + 'Pipelines & Transforms', + 'Broad defaults for pipeline/transform repos with dataset and ontology workflows.', + [/pipeline/i, /transform/i, /dataset/i, /ontology/i, /lineage/i] +); diff --git a/src/palantir-mcp/profiles/policy-types.ts b/src/palantir-mcp/profiles/policy-types.ts new file mode 100644 index 0000000..f44fd04 --- /dev/null +++ b/src/palantir-mcp/profiles/policy-types.ts @@ -0,0 +1,16 @@ +import type { ProfileId } from '../types.ts'; + +export type AgentToolPolicy = { + defaultAllow: 'all' | 'read_only'; + allowPatterns?: readonly RegExp[]; + denyPatterns?: readonly RegExp[]; + denyTools?: readonly string[]; +}; + +export type ProfilePolicy = { + id: ProfileId; + title: string; + description: string; + librarian: AgentToolPolicy; + foundry: AgentToolPolicy; +}; diff --git a/src/palantir-mcp/profiles/shared.ts b/src/palantir-mcp/profiles/shared.ts new file mode 100644 index 0000000..d8c7726 --- /dev/null +++ b/src/palantir-mcp/profiles/shared.ts @@ -0,0 +1,30 @@ +import type { ProfileId } from '../types.ts'; +import type { AgentToolPolicy, ProfilePolicy } from './policy-types.ts'; + +const DESTRUCTIVE_DENY_PATTERNS: readonly RegExp[] = [ + /(?:^|[_-])(delete|destroy|purge|wipe|drop)(?:$|[_-])/i, + /permanently[_-]?delete/i, +]; + +function buildBroadAgentPolicy(extraAllowPatterns: readonly RegExp[] = []): AgentToolPolicy { + return { + defaultAllow: 'all', + allowPatterns: extraAllowPatterns, + denyPatterns: DESTRUCTIVE_DENY_PATTERNS, + }; +} + +export function createBroadProfile( + id: ProfileId, + title: string, + description: string, + extraAllowPatterns: readonly RegExp[] = [] +): ProfilePolicy { + return { + id, + title, + description, + librarian: buildBroadAgentPolicy(extraAllowPatterns), + foundry: buildBroadAgentPolicy(extraAllowPatterns), + }; +} diff --git a/src/palantir-mcp/profiles/unknown.ts b/src/palantir-mcp/profiles/unknown.ts new file mode 100644 index 0000000..93120f7 --- /dev/null +++ b/src/palantir-mcp/profiles/unknown.ts @@ -0,0 +1,8 @@ +import { createBroadProfile } from './shared.ts'; + +export const unknownProfilePolicy = createBroadProfile( + 'unknown', + 'Unknown', + 'Broad defaults when repo signals are inconclusive; platform authorization remains the guardrail.', + [/compute/i, /pipeline/i, /transform/i, /osdk/i, /ontology/i] +); diff --git a/src/palantir-mcp/repo-scan.ts b/src/palantir-mcp/repo-scan.ts index 099af01..d79f547 100644 --- a/src/palantir-mcp/repo-scan.ts +++ b/src/palantir-mcp/repo-scan.ts @@ -15,6 +15,7 @@ type PackageJson = { dependencies?: Record; devDependencies?: Record; peerDependencies?: Record; + scripts?: Record; }; async function pathExists(p: string): Promise { @@ -52,10 +53,35 @@ function addScore( reasons.push(reason); } +function hasComputeModuleSignal(text: string): boolean { + return ( + /compute[\s._-]*modules?/i.test(text) || + /foundry[\s._-]*compute/i.test(text) || + /dev[\s._-]*console/i.test(text) || + /network[\s._-]*egress/i.test(text) + ); +} + function pickBestProfile(scores: Record): ProfileId { const threshold: number = 3; - const ordered: ProfileId[] = ['all', 'pipelines_transforms', 'osdk_functions_ts', 'unknown']; + const tsScore: number = scores.compute_modules_ts; + const pyScore: number = scores.compute_modules_py; + const specificThreshold: number = 4; + if (tsScore >= specificThreshold && pyScore < specificThreshold) return 'compute_modules_ts'; + if (pyScore >= specificThreshold && tsScore < specificThreshold) return 'compute_modules_py'; + if (tsScore >= specificThreshold && pyScore >= specificThreshold) return 'compute_modules'; + + const ordered: ProfileId[] = [ + 'compute_modules_ts', + 'compute_modules_py', + 'compute_modules', + 'all', + 'pipelines_transforms', + 'osdk_functions_ts', + 'unknown', + ]; + let best: ProfileId = 'unknown'; let bestScore: number = -1; @@ -106,7 +132,9 @@ async function collectSampleFiles(root: string, limit: number): Promise = new Set([ '.md', '.ts', + '.tsx', '.js', + '.jsx', '.py', '.yaml', '.yml', @@ -151,8 +179,24 @@ async function collectSampleFiles(root: string, limit: number): Promise, + reasons: string[], + root: string, + relPath: string +): Promise { + const fullPath: string = path.join(root, relPath); + return pathExists(fullPath).then((exists) => { + if (!exists) return; + addScore(scores, reasons, 'compute_modules', 3, `Found ${relPath} directory`); + }); +} + export async function scanRepoForProfile(root: string): Promise { const scores: Record = { + compute_modules: 0, + compute_modules_ts: 0, + compute_modules_py: 0, pipelines_transforms: 0, osdk_functions_ts: 0, all: 0, @@ -160,25 +204,29 @@ export async function scanRepoForProfile(root: string): Promise }; const reasons: string[] = []; - const candidates: Array<{ p: string; profile: ProfileId; score: number; reason: string }> = [ - { p: 'pnpm-workspace.yaml', profile: 'all', score: 3, reason: 'Found pnpm-workspace.yaml' }, - { p: 'turbo.json', profile: 'all', score: 3, reason: 'Found turbo.json' }, - { p: 'nx.json', profile: 'all', score: 3, reason: 'Found nx.json' }, - { p: 'lerna.json', profile: 'all', score: 3, reason: 'Found lerna.json' }, + const monorepoCandidates: Array<{ p: string; score: number; reason: string }> = [ + { p: 'pnpm-workspace.yaml', score: 3, reason: 'Found pnpm-workspace.yaml' }, + { p: 'turbo.json', score: 3, reason: 'Found turbo.json' }, + { p: 'nx.json', score: 3, reason: 'Found nx.json' }, + { p: 'lerna.json', score: 3, reason: 'Found lerna.json' }, ]; - for (const c of candidates) { + for (const c of monorepoCandidates) { if (await pathExists(path.join(root, c.p))) { - addScore(scores, reasons, c.profile, c.score, c.reason); + addScore(scores, reasons, 'all', c.score, c.reason); } } const packageJsonPath: string = path.join(root, 'package.json'); const pyprojectPath: string = path.join(root, 'pyproject.toml'); const requirementsPath: string = path.join(root, 'requirements.txt'); + const tsconfigPath: string = path.join(root, 'tsconfig.json'); const hasPackageJson: boolean = await pathExists(packageJsonPath); const hasPyproject: boolean = await pathExists(pyprojectPath); + const hasRequirements: boolean = await pathExists(requirementsPath); + const hasTsconfig: boolean = await pathExists(tsconfigPath); + if (hasPackageJson && hasPyproject) { addScore(scores, reasons, 'all', 2, 'Found both package.json and pyproject.toml'); } @@ -187,16 +235,13 @@ export async function scanRepoForProfile(root: string): Promise const pkg: PackageJson | null = await parsePackageJson(packageJsonPath); if (pkg) { const depKeys: string[] = getAllDependencyKeys(pkg); + const depKeysLower: string[] = depKeys.map((d) => d.toLowerCase()); - if (depKeys.some((d) => d.toLowerCase().includes('osdk') || d.startsWith('@osdk/'))) { + if (depKeysLower.some((d) => d.includes('osdk') || d.startsWith('@osdk/'))) { addScore(scores, reasons, 'osdk_functions_ts', 5, 'package.json includes OSDK dependency'); } - if ( - depKeys.some( - (d) => d.toLowerCase().includes('palantir') || d.toLowerCase().includes('foundry') - ) - ) { + if (depKeysLower.some((d) => d.includes('palantir') || d.includes('foundry'))) { addScore( scores, reasons, @@ -205,6 +250,49 @@ export async function scanRepoForProfile(root: string): Promise 'package.json references palantir/foundry' ); } + + const hasComputeDep: boolean = depKeysLower.some( + (d) => + d.includes('compute-module') || + d.includes('compute_module') || + d.includes('compute.module') || + (d.includes('compute') && d.includes('module')) + ); + if (hasComputeDep) { + addScore( + scores, + reasons, + 'compute_modules', + 5, + 'package.json includes compute-module dependency' + ); + } + + const hasTypeScriptSignal: boolean = depKeysLower.some( + (d) => d === 'typescript' || d === 'tsx' || d === 'ts-node' || d.includes('typescript') + ); + if (hasComputeDep && hasTypeScriptSignal) { + addScore( + scores, + reasons, + 'compute_modules_ts', + 4, + 'package.json indicates TypeScript compute-module setup' + ); + } + + const scriptText: string = Object.values(pkg.scripts ?? {}) + .filter((v) => typeof v === 'string') + .join('\n'); + if (hasComputeModuleSignal(scriptText)) { + addScore( + scores, + reasons, + 'compute_modules', + 2, + 'package.json scripts mention compute-module workflow' + ); + } } } @@ -217,10 +305,26 @@ export async function scanRepoForProfile(root: string): Promise if (/transform/i.test(text)) { addScore(scores, reasons, 'pipelines_transforms', 1, 'pyproject.toml mentions transform'); } + if (hasComputeModuleSignal(text)) { + addScore( + scores, + reasons, + 'compute_modules_py', + 5, + 'pyproject.toml mentions compute modules' + ); + addScore( + scores, + reasons, + 'compute_modules', + 2, + 'pyproject.toml includes compute-module signals' + ); + } } } - if (await pathExists(requirementsPath)) { + if (hasRequirements) { const text: string | null = await readTextFileBounded(requirementsPath, 200_000); if (text) { if (/foundry/i.test(text)) { @@ -229,6 +333,22 @@ export async function scanRepoForProfile(root: string): Promise if (/transform/i.test(text)) { addScore(scores, reasons, 'pipelines_transforms', 1, 'requirements.txt mentions transform'); } + if (hasComputeModuleSignal(text)) { + addScore( + scores, + reasons, + 'compute_modules_py', + 4, + 'requirements.txt mentions compute modules' + ); + addScore( + scores, + reasons, + 'compute_modules', + 2, + 'requirements.txt includes compute-module signals' + ); + } } } @@ -251,23 +371,89 @@ export async function scanRepoForProfile(root: string): Promise addScore(scores, reasons, 'osdk_functions_ts', 2, 'Found src/functions/ directory'); } - const sampleFiles: string[] = await collectSampleFiles(root, 50); - const maxTotalBytes: number = 200_000; + const computeDirs: string[] = [ + 'compute-modules', + 'compute_modules', + 'compute', + path.join('src', 'compute-modules'), + path.join('src', 'compute_modules'), + path.join('src', 'compute'), + path.join('modules', 'compute'), + path.join('internal', 'compute-modules'), + path.join('internal', 'compute_modules'), + ]; + for (const rel of computeDirs) { + await addComputeModuleSignalsFromPath(scores, reasons, root, rel); + } + + const computeConfigFiles: string[] = [ + 'compute-module.yaml', + 'compute-module.yml', + 'compute_module.yaml', + 'compute_module.yml', + 'foundry-compute-module.yaml', + 'foundry-compute-module.yml', + ]; + for (const rel of computeConfigFiles) { + if (await pathExists(path.join(root, rel))) { + addScore(scores, reasons, 'compute_modules', 4, `Found ${rel}`); + } + } + + if (hasTsconfig && scores.compute_modules > 0) { + addScore( + scores, + reasons, + 'compute_modules_ts', + 2, + 'Found tsconfig.json with compute-module signals' + ); + } + if ((hasPyproject || hasRequirements) && scores.compute_modules > 0) { + addScore( + scores, + reasons, + 'compute_modules_py', + 2, + 'Found Python packaging files with compute-module signals' + ); + } + + const sampleFiles: string[] = await collectSampleFiles(root, 75); + const maxTotalBytes: number = 220_000; let consumedBytes: number = 0; let pipelinesHits: number = 0; let osdkHits: number = 0; + let computeHits: number = 0; + let computeTsHits: number = 0; + let computePyHits: number = 0; for (const p of sampleFiles) { if (consumedBytes >= maxTotalBytes) break; const text: string | null = await readTextFileBounded(p, 8000); if (!text) continue; - consumedBytes += text.length; if (/\b(pipeline|pipelines|transform|transforms)\b/i.test(text)) pipelinesHits += 1; if (/\bosdk\b/i.test(text)) osdkHits += 1; + + const relLower: string = path.relative(root, p).toLowerCase(); + const ext: string = path.extname(relLower); + const hasComputeText: boolean = hasComputeModuleSignal(text); + const hasComputePath: boolean = + relLower.includes('compute-module') || relLower.includes('compute_module'); + + if (hasComputeText || hasComputePath) { + computeHits += 1; + if (ext === '.ts' || ext === '.tsx' || ext === '.js' || ext === '.jsx') { + computeTsHits += 1; + } + if (ext === '.py') { + computePyHits += 1; + } + } } if (pipelinesHits >= 3) { @@ -284,7 +470,46 @@ export async function scanRepoForProfile(root: string): Promise addScore(scores, reasons, 'osdk_functions_ts', 2, `Keyword sample hits osdk (${osdkHits})`); } - const profile: ProfileId = pickBestProfile(scores); + if (computeHits >= 2) { + addScore( + scores, + reasons, + 'compute_modules', + 3, + `Keyword sample hits compute-module workflows (${computeHits})` + ); + } + + if (computeTsHits >= 2) { + addScore( + scores, + reasons, + 'compute_modules_ts', + 3, + `Keyword sample hits TypeScript compute-module files (${computeTsHits})` + ); + } + + if (computePyHits >= 2) { + addScore( + scores, + reasons, + 'compute_modules_py', + 3, + `Keyword sample hits Python compute-module files (${computePyHits})` + ); + } + + if (scores.compute_modules_ts >= 3 && scores.compute_modules_py >= 3) { + addScore( + scores, + reasons, + 'compute_modules', + 3, + 'Detected both TypeScript and Python compute-module signals' + ); + } + const profile: ProfileId = pickBestProfile(scores); return { profile, scores, reasons }; } diff --git a/src/palantir-mcp/types.ts b/src/palantir-mcp/types.ts index 20d383c..1054c6c 100644 --- a/src/palantir-mcp/types.ts +++ b/src/palantir-mcp/types.ts @@ -1,6 +1,26 @@ -export type ProfileId = 'pipelines_transforms' | 'osdk_functions_ts' | 'all' | 'unknown'; +export type ProfileId = + | 'compute_modules' + | 'compute_modules_ts' + | 'compute_modules_py' + | 'pipelines_transforms' + | 'osdk_functions_ts' + | 'all' + | 'unknown'; + +export const PROFILE_IDS: readonly ProfileId[] = [ + 'compute_modules', + 'compute_modules_ts', + 'compute_modules_py', + 'pipelines_transforms', + 'osdk_functions_ts', + 'all', + 'unknown', +]; export function parseProfileId(value: unknown): ProfileId | null { + if (value === 'compute_modules') return value; + if (value === 'compute_modules_ts') return value; + if (value === 'compute_modules_py') return value; if (value === 'pipelines_transforms') return value; if (value === 'osdk_functions_ts') return value; if (value === 'all') return value; From bd9b8b9dc72a190be500b2f216ed686a183ee7f8 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Mon, 16 Feb 2026 15:12:23 -0600 Subject: [PATCH 2/5] chore: address coderabbit nitpick feedback --- src/index.ts | 14 ++++++++++---- src/palantir-mcp/types.ts | 10 +++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index b800368..d4759df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,16 +68,22 @@ const plugin: Plugin = async (input) => { if (!cfg.command['setup-palantir-mcp']) { cfg.command['setup-palantir-mcp'] = { template: 'Set up palantir-mcp for this repo.', - description: - 'Guided MCP setup for Foundry. Usage: /setup-palantir-mcp [--profile ]. Requires FOUNDRY_TOKEN for tool discovery.', + description: [ + 'Guided MCP setup for Foundry.', + 'Usage: /setup-palantir-mcp [--profile ].', + 'Requires FOUNDRY_TOKEN for tool discovery.', + ].join(' '), }; } if (!cfg.command['rescan-palantir-mcp-tools']) { cfg.command['rescan-palantir-mcp-tools'] = { template: 'Re-scan palantir-mcp tools and patch tool gating.', - description: - 'Re-discovers the palantir-mcp tool list and adds missing palantir-mcp_* toggles (does not overwrite existing toggles). Usage: /rescan-palantir-mcp-tools [--profile ]. Requires FOUNDRY_TOKEN.', + description: [ + 'Re-discovers the palantir-mcp tool list and adds missing palantir-mcp_* toggles', + '(does not overwrite existing toggles).', + 'Usage: /rescan-palantir-mcp-tools [--profile ]. Requires FOUNDRY_TOKEN.', + ].join(' '), }; } } diff --git a/src/palantir-mcp/types.ts b/src/palantir-mcp/types.ts index 1054c6c..9b783f5 100644 --- a/src/palantir-mcp/types.ts +++ b/src/palantir-mcp/types.ts @@ -18,12 +18,8 @@ export const PROFILE_IDS: readonly ProfileId[] = [ ]; export function parseProfileId(value: unknown): ProfileId | null { - if (value === 'compute_modules') return value; - if (value === 'compute_modules_ts') return value; - if (value === 'compute_modules_py') return value; - if (value === 'pipelines_transforms') return value; - if (value === 'osdk_functions_ts') return value; - if (value === 'all') return value; - if (value === 'unknown') return value; + for (const profileId of PROFILE_IDS) { + if (value === profileId) return profileId; + } return null; } From cae2bde0b4c1297243ce8b6b76c51d6562ea7d7a Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Mon, 16 Feb 2026 15:35:38 -0600 Subject: [PATCH 3/5] chore: use easter egg setup URL example --- src/palantir-mcp/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/palantir-mcp/commands.ts b/src/palantir-mcp/commands.ts index 8cb59c1..3ae0a91 100644 --- a/src/palantir-mcp/commands.ts +++ b/src/palantir-mcp/commands.ts @@ -94,7 +94,7 @@ function setupUsageText(): string { `Valid profile IDs: ${formatProfileChoices()}`, '', 'Example:', - ' /setup-palantir-mcp https://23dimethyl.usw-3.palantirfoundry.com --profile compute_modules_ts', + ' /setup-palantir-mcp https://totally-not-skynet.palantirfoundry.com --profile compute_modules_ts', ].join('\n'); } From 764299aeb080346f32e4d2936594467df7926410 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Mon, 16 Feb 2026 15:51:02 -0600 Subject: [PATCH 4/5] chore: use punny setup URL easter egg --- src/palantir-mcp/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/palantir-mcp/commands.ts b/src/palantir-mcp/commands.ts index 3ae0a91..7ad7658 100644 --- a/src/palantir-mcp/commands.ts +++ b/src/palantir-mcp/commands.ts @@ -94,7 +94,7 @@ function setupUsageText(): string { `Valid profile IDs: ${formatProfileChoices()}`, '', 'Example:', - ' /setup-palantir-mcp https://totally-not-skynet.palantirfoundry.com --profile compute_modules_ts', + ' /setup-palantir-mcp https://who-let-the-dag-out.palantirfoundry.com --profile compute_modules_ts', ].join('\n'); } From 587cddccf11c22548b1e30b718a5047a0ae3024a Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Mon, 16 Feb 2026 16:19:58 -0600 Subject: [PATCH 5/5] chore: update setup example URL --- src/palantir-mcp/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/palantir-mcp/commands.ts b/src/palantir-mcp/commands.ts index 7ad7658..02a41b3 100644 --- a/src/palantir-mcp/commands.ts +++ b/src/palantir-mcp/commands.ts @@ -94,7 +94,7 @@ function setupUsageText(): string { `Valid profile IDs: ${formatProfileChoices()}`, '', 'Example:', - ' /setup-palantir-mcp https://who-let-the-dag-out.palantirfoundry.com --profile compute_modules_ts', + ' /setup-palantir-mcp https://buschlightdid911.usw-3.palantirfoundry.com --profile compute_modules_ts', ].join('\n'); }