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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,47 @@ The machine record is the SDKs' existing `MainSequenceCLI.auth` service, backend

The `mcp-discover`, `mcp-add`, `mcp-list`, `mcp-remove`, `mcp-tools` and `mcp-call` actions discover the platform's MCP directory, remember a selected release and invoke its tools through a local MCP client, using the existing developer session and short-lived release credentials. They are available from `ms-devtools` and from `createDevTools().dispatch()`; hosts may inject `mcpConnectionsPath`. Since 0.3.1, `mcp-add` reads the release's name, its repository's name and its repository branch from the release directory and stores them with the connection as `name`, `display_name` and `code_repository_branch_uid`, so `mcp-list` labels a saved connection without a backend request; a record saved by an earlier version is completed by the first listing that can read them, and a label that cannot be read is left out rather than making the MCP unavailable. Discovery rows carry `name` and `code_repository_branch_uid` as the platform directory reports them. The conversational `mainsequence-mcp` skill that lets Codex use them without `/ms` ships with the [Codex plugin](https://github.com/mainsequence-sdk/mainsequence-codex-plugin), together with [its user guide](https://github.com/mainsequence-sdk/mainsequence-codex-plugin/blob/main/docs/conversational-mcp.md) and [ADR 0002](https://github.com/mainsequence-sdk/mainsequence-codex-plugin/blob/main/docs/adr/0002-conversational-project-mcp-client.md).

## Shared discovery API (0.3.2)

`createDevTools().dispatch()` now infers typed payloads for session status, environments,
agent directories, MCP discovery, saved connections and tool catalogs. `ActionResult<T>`
defaults to `unknown` for compatibility; the public exports include `McpConnection`,
`McpTool`, `McpCatalog`, `McpDiscoveryPage`, `AgentDirectory` and `DiscoveryResultMap`.
Backend response validation stays in devtools; host applications own their view state.

```ts
const verified = await devtools.dispatch({ action: 'auth-status', backend, check: true });
if (verified.status !== 'success' || !verified.data?.username) throw new Error('Sign in first');
const scope = { backend, expectedAccount: verified.data.username, environmentUid };
const saved = await devtools.dispatch({ action: 'mcp-list', ...scope, readOnly: true });
const available = await devtools.dispatch({ action: 'mcp-discover', ...scope, enrichNames: true, limit: 20 });
```

- `mcp-list` with `readOnly: true` reads saved metadata without label lookups, locks or
connection-file writes. Missing stores remain absent; corrupt files are preserved and
reported. Omit the option (or explicitly use `false`) to retain the legacy label-backfill
behavior. Normal authentication renewal is independent of connection metadata writes.
- `mcp-discover` with `enrichNames: true` resolves optional repository names once per branch,
with at most four concurrent lookups and no connection writes. Missing optional metadata
leaves usable endpoints visible. Cancellation and account changes still fail the action.
- `mcp-environments` and `organization-environments` use the same bounded `listEnvironments`
loader. Short pages advance by their actual length; duplicate rows, changing totals,
premature empty pages and directories over 10,000 entries are refused. The existing MCP
result retains `isProduction`; the Projects result retains `{uid, name}`.
- MCP and organization-project actions accept optional `expectedAccount`. The built-in
session verifies the actual credential used for requests and retries, rejects a different
account and discards responses when the selected credentials change during a read. Agent
operations also use this binding when the session supports it. This does not make remote
mutations atomic with account changes or roll back work already dispatched.

`bindSessionAccount(session, backend, expectedAccount)` is available for public domain
clients. Custom `SessionService` implementations can implement the optional `withAccount`
method to provide that contract. New opt-in MCP/Projects account checks fail with
`UNSUPPORTED_ACTION` when the custom transport lacks it; existing unscoped callers and
legacy custom agent transports retain their prior behavior. The built-in scoped session
cannot log in, log out or change authentication. Hosts still invalidate stale selections
when their configured backend/environment changes.

## Project actions

Backend selection uses an explicit `--backend`, process `MAINSEQUENCE_ENDPOINT`, the Git-root `.env` endpoint, existing CLI configuration, then the standard backend. Project selection validates the real Git root, attached branch, full HEAD SHA, and canonical remote identity. Ambiguous Python/frontend projects require `--type python` or `--type vite_react`.
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.3.1",
"version": "0.3.2",
"description": "Shared MainSequence developer actions, project operations, MCP client and command runner",
"license": "MIT",
"engines": {
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export { pythonExecutable, hasPythonProject, projectVersion, readPythonSdkStatus
export type { CaptureProcess } from './shared/pythonProjectStatus';

export type { McpRequest } from './shared/mcp';
export type { ActionData, DiscoveryResultMap, AgentDirectory, McpConnection, McpDiscoveryPage, McpCatalog, McpTool } from './shared/discoveryTypes';
export { listEnvironments } from './shared/environments';
export type { EnvironmentSummary } from './shared/environments';
export { bindSessionAccount } from './shared/accountScope';
export { AgentClient } from './shared/agents';
export type { AgentRequest, PlatformAgent } from './shared/agents';

Expand Down
15 changes: 15 additions & 0 deletions src/shared/accountScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { SessionService, ToolingError } from './models';

export function validateExpectedAccount(value: unknown): asserts value is string {
if (typeof value !== 'string' || !value.trim() || value.length > 300 || /[\r\n\0]/.test(value)) {
throw new ToolingError('INVALID_REQUEST', 'Use the account returned by verified session status.');
}
}

/** Opt-in account scope for domain operations; never silently weaken a custom transport. */
export async function bindSessionAccount(session: SessionService, backend: string, expectedAccount?: string): Promise<SessionService> {
if (expectedAccount === undefined) return session;
validateExpectedAccount(expectedAccount);
if (!session.withAccount) throw new ToolingError('UNSUPPORTED_ACTION', 'This session transport does not support account-bound operations. Upgrade its session implementation.');
return session.withAccount(backend, expectedAccount);
}
14 changes: 11 additions & 3 deletions src/shared/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import { listRepositories, projectContext, resolveBackend, resolveRepository } f
import { DRY_RUN_ACTIONS, projectCapabilities, runProjectAction, setupRepository, withProjectLock } from './projects';
import { OrganizationProjectClient, OrganizationProjectRequest } from './organizationProjects';
import { setupOrganizationProject } from './organizationProjectWorkspace';
import { bindSessionAccount } from './accountScope';
import type { ActionData } from './discoveryTypes';

export function createDevTools(options: ConstructorParameters<typeof MachineSessionService>[1] & { host: HostServices; session?: SessionService & { sanitize?(text: string): string }; allowMcpSkills?: boolean; allowLegacyProjectCredentials?: boolean; mcpConnectionsPath?: string }) {
const { host, session: suppliedSession, allowMcpSkills, allowLegacyProjectCredentials, mcpConnectionsPath, ...sessionOptions } = options;
const session = suppliedSession ?? new MachineSessionService(host, sessionOptions);
const env = { ...(options.env ?? process.env) };
function dispatch<R extends ActionRequest>(request: R): Promise<ActionResult<ActionData<R>>>;
async function dispatch(request: ActionRequest): Promise<ActionResult> {
const state = { mutationStarted: false };
const action = request?.action || 'unknown';
Expand All @@ -20,17 +23,20 @@ export function createDevTools(options: ConstructorParameters<typeof MachineSess
if (action.startsWith('organization-')) {
const input = request as OrganizationProjectRequest;
const backend = await resolveBackend(request.backend, undefined, env);
const client = new OrganizationProjectClient(session, backend, options.signal);
const scoped = await bindSessionAccount(session, backend, input.expectedAccount);
const client = new OrganizationProjectClient(scoped, backend, options.signal);
let data: unknown;
if (input.action === 'organization-environments') data = await client.environments();
else if (input.action === 'organization-projects') data = await client.list(input.environmentUid, input);
else if (input.action === 'organization-project') data = await client.getProject(input.environmentUid, input.projectUid);
else if (input.action === 'organization-project-link') {
const url = await client.link(input);
if (input.expectedAccount !== undefined) await scoped.status(backend, true);
if (!await host.openBrowser(url)) throw new ToolingError('BROWSER_FAILED', 'The resource link could not open.');
data = { url };
} else if (input.action === 'organization-project-setup') data = await setupOrganizationProject({ ...input, backend }, { host, session, env, signal: options.signal, state });
} else if (input.action === 'organization-project-setup') data = await setupOrganizationProject({ ...input, backend }, { host, session: scoped, env, signal: options.signal, state });
else throw new ToolingError('INVALID_REQUEST', 'Unknown organization project action.');
if (input.expectedAccount !== undefined) await scoped.status(backend, true);
return safeResult({ schemaVersion: 1, action, status: 'success', summary: input.action === 'organization-project-setup' ? 'Project checkouts are prepared on their assigned branches. Claude must attach them when its workspace session starts.' : 'Projects loaded.', data });
}
if (isAgentAction(action)) {
Expand All @@ -39,7 +45,9 @@ export function createDevTools(options: ConstructorParameters<typeof MachineSess
}
if (action.startsWith('mcp-')) {
const backend = await resolveBackend(request.backend, process.cwd(), env);
return safeResult(await new McpConnections(session, { connectionsPath: mcpConnectionsPath, env, fetch: options.fetch, signal: options.signal, requestTimeoutMs: options.requestTimeoutMs }).dispatch(request as McpRequest, backend));
const input = request as McpRequest, scoped = await bindSessionAccount(session, backend, input.expectedAccount);
return safeResult(await new McpConnections(scoped, { connectionsPath: mcpConnectionsPath, env, fetch: options.fetch, signal: options.signal, requestTimeoutMs: options.requestTimeoutMs,
checkAccount: input.expectedAccount === undefined ? undefined : () => scoped.status(backend, true).then(() => {}) }).dispatch(input, backend));
}
let data: unknown;
if (['login', 'logout', 'refresh-token', 'auth-status', 'repositories'].includes(action)) {
Expand Down
6 changes: 5 additions & 1 deletion src/shared/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,15 @@ export class AgentClient {
if (['agent-session', 'agent-readiness', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(input.action) && !input.expectedAccount) {
throw new ToolingError('INVALID_REQUEST', 'Agent conversations must be bound to the account returned by discovery.');
}
// The built-in session binds every request/retry to the selected account. Legacy
// custom agent transports retain their existing account-check contract.
const session = input.expectedAccount !== undefined && this.session.withAccount
? await this.session.withAccount(backend, input.expectedAccount) : this.session;
const timeouts = new Map<string, number>();
const client = new McpTransport(message => {
const remaining = readinessDeadline === undefined ? undefined : Math.ceil(readinessDeadline - performance.now());
if (remaining !== undefined && remaining <= 0) throw new ToolingError('PROCESS_TIMEOUT', 'The agent did not become ready in time. No message was sent. Try again.');
return this.session.request(backend, 'POST', '/mcp', message,
return session.request(backend, 'POST', '/mcp', message,
message.method !== 'tools/call' || !['a2a.send_message', 'a2a.cancel_task'].includes((message.params as any)?.name),
remaining !== undefined ? { timeoutMs: Math.min(30000, remaining) }
: message.method === 'tools/call' && timeouts.has((message.params as any)?.name) ? { timeoutMs: timeouts.get((message.params as any).name) } : undefined);
Expand Down
33 changes: 33 additions & 0 deletions src/shared/discoveryTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ActionRequest, SessionStatus } from './models';
import type { PlatformAgent } from './agents';
import type { EnvironmentSummary } from './environments';
import type { OrganizationEnvironment, OrganizationProjectPage } from './organizationProjects';

export type McpConnection = {
backend: string; resource_release_uid: string; name?: string; display_name?: string; code_repository_branch_uid?: string;
mcp_connection: { url: string; transport: 'streamable-http'; resource_metadata_url: string;
organization_environment_uid: string; organization_environment_name?: string; active_revision_uid: string };
};
export type McpTool = {
name: string; description?: string; inputSchema: Record<string, unknown>;
annotations?: Record<string, unknown> & { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean };
_meta?: Record<string, unknown>;
[key: string]: unknown;
};
export type McpDiscoveryPage = { count: number; results: McpConnection[] };
export type McpCatalog = { connection: McpConnection; tools: McpTool[] };
export type AgentDirectory = { account: string; results: PlatformAgent[]; count: number; offset: number; semantic: boolean };

/** Additive typed read contracts; unrelated actions retain their existing unknown payload. */
export type DiscoveryResultMap = {
'auth-status': SessionStatus;
'mcp-environments': EnvironmentSummary[];
'organization-environments': OrganizationEnvironment[];
'organization-projects': OrganizationProjectPage;
'agents-list': AgentDirectory;
'mcp-discover': McpDiscoveryPage;
'mcp-list': McpConnection[];
'mcp-tools': McpCatalog;
};
export type ActionData<R extends ActionRequest> = R extends { action: infer A }
? A extends keyof DiscoveryResultMap ? DiscoveryResultMap[A] : unknown : unknown;
34 changes: 34 additions & 0 deletions src/shared/environments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { SessionService, ToolingError } from './models';
import { requireBackend } from './session';

export type EnvironmentSummary = { uid: string; name: string; isProduction: boolean };

/** One bounded directory reader for all hosts and domain clients. No settings writes. */
export async function listEnvironments(session: Pick<SessionService, 'request'>, inputBackend: string, signal?: AbortSignal): Promise<EnvironmentSummary[]> {
const backend = requireBackend(inputBackend), items: EnvironmentSummary[] = [], seen = new Set<string>();
let expectedCount: number | undefined;
for (let offset = 0; ;) {
signal?.throwIfAborted();
const page = await session.request<any>(backend, 'GET', `/api/v1/organization-environments/?limit=100&offset=${offset}`);
signal?.throwIfAborted();
const rows = Array.isArray(page) ? page : page?.results;
const count = Array.isArray(page) ? page.length : page?.count;
if (!Array.isArray(rows) || !Number.isSafeInteger(count) || count < 0 || count > 10_000 || offset + rows.length > count
|| (expectedCount !== undefined && expectedCount !== count) || (offset > 0 && Array.isArray(page))) {
throw new ToolingError('API_ERROR', 'The environment pagination is invalid or changed. Refresh the directory.');
}
expectedCount = count;
for (const row of rows) {
if (typeof row?.uid !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(row.uid)
|| typeof row.name !== 'string' || !row.name.trim() || /[\r\n\0]/.test(row.name)) {
throw new ToolingError('API_ERROR', 'The backend returned an invalid organization environment.');
}
const uid = row.uid.toLowerCase();
if (seen.has(uid)) throw new ToolingError('API_ERROR', 'Environment pagination returned duplicate rows.');
seen.add(uid); items.push({ uid, name: row.name.trim(), isProduction: row.is_production === true });
}
offset += rows.length;
if (offset === count) return items;
if (!rows.length) throw new ToolingError('API_ERROR', 'Environment pagination stopped before completion.');
}
}
Loading
Loading