diff --git a/README.md b/README.md index 00ac14c..57a090e 100644 --- a/README.md +++ b/README.md @@ -131,3 +131,6 @@ See [ADR 0001](docs/adr/0001-shared-developer-tooling-and-codex.md), [Phase-1 ve Version 0.2.2 adds the [public project operations API](docs/shared-project-operations.md). Claude Desktop is maintained in [its own repository](https://github.com/mainsequence-sdk/mainsequence-claude-desktop). See the [integration roadmap](docs/integration-roadmap.md) for ownership and deferred Codex/OpenCode extractions. + +Workspace defaults and environment-scoped MCP host APIs are documented in +[workspace settings and environments](docs/workspace-environments.md). diff --git a/docs/workspace-environments.md b/docs/workspace-environments.md new file mode 100644 index 0000000..17100a3 --- /dev/null +++ b/docs/workspace-environments.md @@ -0,0 +1,26 @@ +# Workspace defaults and MCP environments + +`readWorkspacePreferences({ env?, configPath?, homeDir? })` resolves only the backend +URL and repositories folder. It never signs in or rewrites configuration. The +backend uses `MAINSEQUENCE_ENDPOINT`, then existing `backend_url`, then +`https://api.main-sequence.app`. The folder uses an absolute `mainsequence_path`, +otherwise `/mainsequence`. Host-saved overrides remain the host's responsibility. + +The structured `createDevTools().dispatch` API accepts `mcp-environments` to read +all authorized organization environments through the existing paginated backend +endpoint. MCP requests accept an optional `environmentUid`. Discovery scans the +existing MCP directory before paginating matching releases, because the backend +MCP tool does not accept an environment filter. Scans are bounded to 10,000 entries; +an oversized directory fails with a request to narrow the search, rather than +pretending a partial list is complete. No backend modification is required. + +Environment-scoped listing leaves other saved connections intact. Connecting, +reading tools and calling a tool recheck the release's current environment before +exchanging credentials. Removing a connection checks its saved environment. Existing +metadata without environment names remains readable; new names are retained when +provided. Omitting `environmentUid` preserves the prior API behavior for other hosts. + +Tests cover default/config/environment precedence, no configuration writes, +filtered results on later pages, legacy metadata, and rejection of cross-environment +calls before credential exchange. The CLI flags are unchanged; these additions are +public structured APIs for host adapters. diff --git a/manifests/codex/.codex-plugin/plugin.json b/manifests/codex/.codex-plugin/plugin.json index 04e99ac..b53cfb1 100644 --- a/manifests/codex/.codex-plugin/plugin.json +++ b/manifests/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mainsequence-devtools", - "version": "0.2.2", + "version": "0.2.3", "description": "Use MainSequence project MCPs from conversation and run explicit developer commands with the bundled runner.", "author": { "name": "MainSequence" diff --git a/package-lock.json b/package-lock.json index 4d8812b..0e30470 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dev-mainsequence/devtools", - "version": "0.2.2", + "version": "0.2.3", "license": "MIT", "bin": { "ms-devtools": "dist/runner.js" diff --git a/package.json b/package.json index aee377e..94bde23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.2.2", + "version": "0.2.3", "description": "Shared MainSequence developer actions, project operations and deterministic Codex adapter", "license": "MIT", "engines": { diff --git a/src/index.ts b/src/index.ts index 9fcb72f..341c8f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ export { clearSession, readSession, saveSession, SESSION_SERVICE, CredentialStor export type { MachineSession, ProgramRunner } from './shared/machineSession'; export type { ProjectAction, SessionPersistence } from './shared/models'; export { normalizeBackendUrl } from './shared/config'; -export { projectContext, detectProjectType, canonicalRepositoryDir, safeSlug } from './shared/repositories'; +export { readWorkspacePreferences, projectContext, detectProjectType, canonicalRepositoryDir, safeSlug } from './shared/repositories'; export { RepositoryClient } from './shared/repositoryClient'; export type { CodeRepository, CodeRepositoryBranch, CodeRepositoryGitContextResolution, GitHubRepositoryBinding } from './shared/repositoryModels'; export { repositorySshKeyIdentity, repositorySshKeyName, repositorySshKeyPaths } from './shared/repositoryIdentity'; diff --git a/src/shared/actions.ts b/src/shared/actions.ts index 07be185..0660b47 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -73,7 +73,7 @@ const RETIRED_ACTIONS: Record = { 'freeze-env': 'freeze-env was removed: sync now refreshes the lock file, the environment and requirements.txt. Run sync instead.', }; export function validateRequest(request: ActionRequest): void { - if (request && ['mcp-discover', 'mcp-add', 'mcp-list', 'mcp-remove', 'mcp-tools', 'mcp-call'].includes(request.action)) { + if (request && ['mcp-environments', 'mcp-discover', 'mcp-add', 'mcp-list', 'mcp-remove', 'mcp-tools', 'mcp-call'].includes(request.action)) { if (request.backend !== undefined && typeof request.backend !== 'string') throw new ToolingError('INVALID_REQUEST', 'Backend must be a URL string.'); return; } diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts index 20fcfa8..af85032 100644 --- a/src/shared/mcp.ts +++ b/src/shared/mcp.ts @@ -6,16 +6,17 @@ import { ActionResult, SessionService, ToolingError } from './models'; import { requireBackend } from './session'; import { McpTransport, sendProjectMcp } from './mcpTransport'; -export type McpRequest = { backend?: string } & ( +export type McpRequest = { backend?: string; environmentUid?: string } & ( | { action: 'mcp-discover'; search?: string; limit?: number; offset?: number } | { action: 'mcp-list' } + | { action: 'mcp-environments' } | { action: 'mcp-add' | 'mcp-remove' | 'mcp-tools'; releaseUid: string } | { action: 'mcp-call'; releaseUid: string; tool: string; arguments: Record } ); type Connection = { backend: string; resource_release_uid: string; name: string; mcp_connection: { url: string; transport: 'streamable-http'; resource_metadata_url: string; - organization_environment_uid: string; active_revision_uid: string }; + organization_environment_uid: string; organization_environment_name?: string; active_revision_uid: string }; }; function uuid(value: unknown): string { @@ -47,7 +48,8 @@ function connection(backend: string, row: any): Connection { || metadataQuery.get('resource_release_uid') !== releaseUid) throw new ToolingError('API_ERROR', 'The MCP metadata targets another release.'); return { backend: requireBackend(backend), resource_release_uid: releaseUid, name: row.name, mcp_connection: { url, transport: 'streamable-http', resource_metadata_url: metadataUrl, - organization_environment_uid: uuid(c.organization_environment_uid), active_revision_uid: uuid(c.active_revision_uid) } }; + organization_environment_uid: uuid(c.organization_environment_uid), + ...(typeof c.organization_environment_name === 'string' && c.organization_environment_name.trim() ? { organization_environment_name: c.organization_environment_name.trim() } : {}), active_revision_uid: uuid(c.active_revision_uid) } }; } function storagePath(env: NodeJS.ProcessEnv): string { const dir = process.platform === 'darwin' ? path.join(os.homedir(), 'Library', 'Application Support', 'MainSequenceCLI') @@ -132,8 +134,32 @@ export class McpConnections { try { const backend = requireBackend(inputBackend); this.options.signal?.throwIfAborted(); + const environmentUid = request.environmentUid === undefined ? undefined : uuid(request.environmentUid); + const belongs = (value: Connection) => !environmentUid || value.mcp_connection.organization_environment_uid === environmentUid; + const requireEnvironment = (value: Connection) => { + if (!belongs(value)) throw new ToolingError('INVALID_REQUEST', 'This MCP belongs to a different organization environment. Select that environment first.'); + }; let data: unknown, summary: string; - if (request.action === 'mcp-discover') { + if (request.action === 'mcp-environments') { + const environments = new Map(); + for (let offset = 0; ; offset += 100) { + this.options.signal?.throwIfAborted(); + if (offset >= 10000) throw new ToolingError('API_ERROR', 'The environment list is too large.'); + const page = await this.session.request(backend, 'GET', `/api/v1/organization-environments/?limit=100&offset=${offset}`); + const items = Array.isArray(page) ? page : page?.results; + if (!Array.isArray(items)) throw new ToolingError('API_ERROR', 'The backend returned an invalid environment list.'); + for (const row of items) { + if (typeof row?.name !== 'string' || !row.name.trim()) throw new ToolingError('API_ERROR', 'An organization environment has no name.'); + const uid = uuid(row.uid); + environments.set(uid, { uid, name: row.name.trim(), isProduction: row.is_production === true }); + } + if (Array.isArray(page)) break; + if (!Number.isInteger(page.count) || page.count < 0) throw new ToolingError('API_ERROR', 'The environment pagination is invalid.'); + if (offset + 100 >= page.count) break; + if (!items.length) throw new ToolingError('API_ERROR', 'The environment directory changed; refresh it.'); + } + data = [...environments.values()]; summary = 'Organization environments available to your MainSequence account.'; + } else if (request.action === 'mcp-discover') { if ((request.search !== undefined && typeof request.search !== 'string') || (request.limit !== undefined && (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > 100)) || (request.offset !== undefined && (!Number.isInteger(request.offset) || request.offset < 0))) { @@ -141,24 +167,43 @@ export class McpConnections { } const client = new McpTransport(message => this.session.request(backend, 'POST', '/mcp', message, true)); await client.initialize(); - const output = await client.request('tools/call', { name: 'resource_release.mcp_endpoints', - arguments: { ...(request.search !== undefined ? { search: request.search } : {}), - limit: request.limit ?? 100, offset: request.offset ?? 0 } }); - if (output?.isError) throw new ToolingError('API_ERROR', 'Platform MCP could not list available project endpoints.'); - const page = output?.structuredContent; - if (!Number.isInteger(page?.count) || page.count < 0 || !Array.isArray(page?.results)) throw new ToolingError('API_ERROR', 'Platform MCP returned an invalid directory.'); - data = { count: page.count, results: page.results.map((row: unknown) => connection(backend, row)) }; + const query = async (offset: number, limit: number) => { + const output = await client.request('tools/call', { name: 'resource_release.mcp_endpoints', + arguments: { ...(request.search !== undefined ? { search: request.search } : {}), limit, offset } }); + if (output?.isError) throw new ToolingError('API_ERROR', 'Platform MCP could not list available project endpoints.'); + const page = output?.structuredContent; + if (!Number.isInteger(page?.count) || page.count < 0 || !Array.isArray(page?.results)) throw new ToolingError('API_ERROR', 'Platform MCP returned an invalid directory.'); + return { count: page.count, results: page.results.map((row: unknown) => connection(backend, row)) as Connection[] }; + }; + if (!environmentUid) data = await query(request.offset ?? 0, request.limit ?? 100); + else { + // The existing platform MCP has no environment filter. Scan its pages + // before paginating the matching releases; never show an incomplete page as complete. + const matching = new Map(); + for (let offset = 0; ; offset += 100) { + this.options.signal?.throwIfAborted(); + if (offset >= 10000) throw new ToolingError('API_ERROR', 'MCP discovery is too large; narrow the search.'); + const page = await query(offset, 100); + for (const value of page.results.filter(belongs)) matching.set(value.resource_release_uid, value); + if (offset + 100 >= page.count) break; + } + const values = [...matching.values()], offset = request.offset ?? 0; + data = { count: values.length, results: values.slice(offset, offset + (request.limit ?? 100)) }; + } summary = 'Available project MCP endpoints; discovery does not save a connection.'; } else if (request.action === 'mcp-list') { - data = (await this.read()).filter(row => row.backend === backend); + data = (await this.read()).filter(row => row.backend === backend && belongs(row)); summary = 'Saved MCP connections for this backend. Availability is checked when used.'; } else { const releaseUid = uuid(request.releaseUid); if (request.action === 'mcp-remove') { + const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === releaseUid); + if (saved) requireEnvironment(saved); await this.update(rows => rows.filter(row => !(row.backend === backend && row.resource_release_uid === releaseUid))); data = { resource_release_uid: releaseUid }; summary = 'The saved connection was removed.'; } else { const value = await this.current(backend, releaseUid); + requireEnvironment(value); if (request.action !== 'mcp-add') { const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === releaseUid); if (!saved) throw new ToolingError('INVALID_REQUEST', 'Add this MCP connection before using it.'); diff --git a/src/shared/repositories.ts b/src/shared/repositories.ts index ed48cf3..9b2a92e 100644 --- a/src/shared/repositories.ts +++ b/src/shared/repositories.ts @@ -30,14 +30,21 @@ export async function resolveBackend(explicit?: string, root?: string, env: Node const projectEnv = await readProjectEnv(root); if (projectEnv.MAINSEQUENCE_ENDPOINT) return requireBackend(projectEnv.MAINSEQUENCE_ENDPOINT); } - const configDir = process.platform === 'darwin' ? path.join(os.homedir(), 'Library', 'Application Support', 'MainSequenceCLI') - : process.platform === 'win32' ? path.join(env.APPDATA || os.homedir(), 'MainSequenceCLI') : path.join(os.homedir(), '.config', 'mainsequence'); - try { - const config = JSON.parse(await fs.readFile(path.join(configDir, 'config.json'), 'utf8')); - if (typeof config.backend_url === 'string') return requireBackend(config.backend_url); - } catch (error) { if (error instanceof ToolingError) throw error; } - return 'https://api.main-sequence.app'; + return (await readWorkspacePreferences({ env })).backend; } + +/** Resolve non-secret workspace settings without requiring a session or writing configuration. */ +export async function readWorkspacePreferences(options: { env?: NodeJS.ProcessEnv; configPath?: string; homeDir?: string } = {}): Promise<{ backend: string; baseDir: string }> { + const env = options.env ?? process.env, home = options.homeDir ?? os.homedir(); + const directory = process.platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'MainSequenceCLI') + : process.platform === 'win32' ? path.join(env.APPDATA || home, 'MainSequenceCLI') : path.join(home, '.config', 'mainsequence'); + const config = await fs.readFile(options.configPath ?? path.join(directory, 'config.json'), 'utf8') + .then(text => JSON.parse(text)).catch(() => ({})); + const folder = config?.mainsequence_path; + return { backend: requireBackend(env.MAINSEQUENCE_ENDPOINT || (typeof config?.backend_url === 'string' && config.backend_url.trim()) || 'https://api.main-sequence.app'), + baseDir: typeof folder === 'string' && path.isAbsolute(folder) && !/[\0\r\n]/.test(folder) ? folder : path.join(home, 'mainsequence') }; +} + export async function git(host: HostServices, cwd: string, args: string[], signal?: AbortSignal): Promise { const result = await host.runProcess({ executable: 'git', args, cwd, signal, timeoutMs: 15_000 }); if (result.cancelled) throw new ToolingError('CANCELLED', 'The action was cancelled.'); diff --git a/tests/workspace-environments.test.cjs b/tests/workspace-environments.test.cjs new file mode 100644 index 0000000..2c4d624 --- /dev/null +++ b/tests/workspace-environments.test.cjs @@ -0,0 +1,49 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'), path = require('node:path'), os = require('node:os'); +const { readWorkspacePreferences, createDevTools, createNodeHost } = require('../dist/index.js'); +const envA='aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', envB='bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; +const uid=n=>`00000000-0000-0000-0000-${String(n).padStart(12,'0')}`; +const backend='https://backend.example'; +const row=(n,environment=envA)=>({backend,name:'Project',resource_release_uid:uid(n),mcp_connection:{ + transport:'streamable-http',url:'https://project.example/mcp',resource_metadata_url:backend+'/.well-known/oauth-protected-resource/mcp?resource_release_uid='+uid(n), + organization_environment_uid:environment,organization_environment_name:environment===envA?'Research':'Production',active_revision_uid:uid(999)}}); +async function temporary(t){const dir=await fs.mkdtemp(path.join(os.tmpdir(),'ms-settings-env-'));t.after(()=>fs.rm(dir,{recursive:true,force:true}));return dir;} +test('workspace values resolve without authentication, preserve configuration, and honor precedence', async t=>{ + const dir=await temporary(t),configPath=path.join(dir,'config.json'); + assert.deepEqual(await readWorkspacePreferences({env:{},homeDir:dir,configPath}),{backend:'https://api.main-sequence.app',baseDir:path.join(dir,'mainsequence')}); + const original=JSON.stringify({backend_url:'http://127.0.0.1:8000',mainsequence_path:dir,provider:{secret:'fixture-secret'}}); + await fs.writeFile(configPath,original); + assert.deepEqual(await readWorkspacePreferences({env:{},homeDir:dir,configPath}),{backend:'http://127.0.0.1:8000',baseDir:dir}); + assert.equal((await readWorkspacePreferences({env:{MAINSEQUENCE_ENDPOINT:'https://override.example'},homeDir:dir,configPath})).backend,'https://override.example'); + assert.equal(await fs.readFile(configPath,'utf8'),original); +}); +test('MCP environments and filtered discovery include later pages without altering saved connections',async t=>{ + const dir=await temporary(t),file=path.join(dir,'mcp.json'),requests=[]; + const legacy=row(1);delete legacy.mcp_connection.organization_environment_name; + const original=JSON.stringify([legacy,row(101,envB)]);await fs.writeFile(file,original); + const session={request:async (b,m,p,body)=>{ + requests.push({p,body}); + if(p.startsWith('/api/v1/organization-environments/'))return {count:2,results:[{uid:envA,name:'Research'},{uid:envB,name:'Production',is_production:true}]}; + if(p==='/mcp'){ + if(body.method==='initialize')return {jsonrpc:'2.0',id:body.id,result:{protocolVersion:'2025-11-25',capabilities:{tools:{}}}}; + if(body.method==='notifications/initialized')return undefined; + const offset=body.params.arguments.offset; + return {jsonrpc:'2.0',id:body.id,result:{structuredContent:{count:101,results:offset===0?Array.from({length:100},(_,i)=>row(i+1)):[row(101,envB)]}}}; + } + if(p==='/api/v1/resource-releases/'+uid(101)+'/')return row(101,envB); + throw new Error('Unexpected credential or project request'); + }}; + const api=createDevTools({host:createNodeHost(),session,env:{},mcpConnectionsPath:file}); + const dispatch=(action,extra={})=>api.dispatch({action,backend,environmentUid:envB,...extra}); + assert.deepEqual((await dispatch('mcp-environments')).data,[{uid:envA,name:'Research',isProduction:false},{uid:envB,name:'Production',isProduction:true}]); + const found=await dispatch('mcp-discover',{limit:25});assert.equal(found.status,'success');assert.equal(found.data.count,1);assert.equal(found.data.results[0].resource_release_uid,uid(101)); + assert.deepEqual(requests.filter(r=>r.body?.method==='tools/call').map(r=>r.body.params.arguments.offset),[0,100]); + assert.equal((await dispatch('mcp-list')).data.length,1); + assert.equal((await dispatch('mcp-tools',{environmentUid:envA,releaseUid:uid(101)})).error.code,'INVALID_REQUEST'); + assert.equal((await dispatch('mcp-call',{environmentUid:envA,releaseUid:uid(101),tool:'write',arguments:{}})).error.code,'INVALID_REQUEST'); + assert.equal((await dispatch('mcp-remove',{environmentUid:envA,releaseUid:uid(101)})).error.code,'INVALID_REQUEST'); + assert.equal(await fs.readFile(file,'utf8'),original); + assert.ok(requests.every(r=>!r.p.includes('exchange-launch'))); + assert.equal((await dispatch('mcp-list',{environmentUid:'invalid'})).error.code,'INVALID_REQUEST'); +});