Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
26 changes: 26 additions & 0 deletions docs/workspace-environments.md
Original file line number Diff line number Diff line change
@@ -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 `<home>/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.
2 changes: 1 addition & 1 deletion manifests/codex/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/shared/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const RETIRED_ACTIONS: Record<string, string> = {
'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;
}
Expand Down
69 changes: 57 additions & 12 deletions src/shared/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }
);
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 {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -132,33 +134,76 @@ 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<string, { uid: string; name: string; isProduction: boolean }>();
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<any>(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))) {
throw new ToolingError('INVALID_REQUEST', 'Discovery requires a text search, a limit from 1 to 100, and a nonnegative offset.');
}
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<string, Connection>();
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.');
Expand Down
21 changes: 14 additions & 7 deletions src/shared/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const result = await host.runProcess({ executable: 'git', args, cwd, signal, timeoutMs: 15_000 });
if (result.cancelled) throw new ToolingError('CANCELLED', 'The action was cancelled.');
Expand Down
Loading
Loading