Guide · AWS Organizations · Account Management
MCP Server AWS Organizations — ListAccounts, CreateAccount, MoveAccount
AWS Organizations lets you centrally manage multiple AWS accounts under a single org. From an MCP server you enumerate all member accounts with ListAccountsCommand, create new accounts asynchronously with CreateAccountCommand, and arrange them into an OU hierarchy with MoveAccountCommand. The two decisions that determine everything else are account creation is async (you must poll DescribeCreateAccountStatusCommand until the status transitions from IN_PROGRESS to SUCCEEDED) and all management API calls must come from the management account (member accounts cannot call Organizations APIs to modify the org structure).
TL;DR
Use ListAccountsCommand (paginated with NextToken) to list all accounts. Create a new account with CreateAccountCommand{AccountName, Email} — it returns a CreateAccountRequestId; poll DescribeCreateAccountStatusCommand until State is SUCCEEDED. Move it to the right OU with MoveAccountCommand{AccountId, SourceParentId, DestinationParentId}. Traverse the hierarchy with ListRootsCommand → ListChildrenCommand{ParentId, ChildType:'ORGANIZATIONAL_UNIT'/'ACCOUNT'}. All calls require management account credentials.
Client setup
OrganizationsClient must be constructed with region: 'us-east-1' regardless of where your MCP server runs — the Organizations API is a global service with a single endpoint in us-east-1. Using any other region will return an EndpointResolutionError.
import {
OrganizationsClient,
ListAccountsCommand,
DescribeOrganizationCommand,
} from '@aws-sdk/client-organizations';
// Organizations is a global service — always us-east-1
const org = new OrganizationsClient({ region: 'us-east-1' });
export async function describeOrganization() {
const response = await org.send(new DescribeOrganizationCommand({}));
const o = response.Organization!;
return {
id: o.Id, // e.g. 'o-exampleorgid11'
masterAccountId: o.MasterAccountId,
featureSet: o.FeatureSet, // 'ALL' or 'CONSOLIDATED_BILLING'
arn: o.Arn,
};
}
The FeatureSet field determines what you can do. ALL means SCPs and tag policies are available; CONSOLIDATED_BILLING means only billing consolidation is active — you cannot attach SCPs until you enable all features with EnableAllFeaturesCommand.
Listing all accounts
ListAccountsCommand is paginated — an org with more than 20 accounts requires you to follow the NextToken chain. Each account has an Id (12-digit), Name, Email, Status (ACTIVE, SUSPENDED, or PENDING_CLOSURE), and JoinedTimestamp.
import {
ListAccountsCommand,
type Account,
} from '@aws-sdk/client-organizations';
export async function listAllAccounts(): Promise<Account[]> {
const accounts: Account[] = [];
let nextToken: string | undefined;
do {
const response = await org.send(
new ListAccountsCommand({ NextToken: nextToken })
);
accounts.push(...(response.Accounts ?? []));
nextToken = response.NextToken;
} while (nextToken);
return accounts;
}
// Find accounts by status
export async function listActiveAccounts(): Promise<Account[]> {
const all = await listAllAccounts();
return all.filter(a => a.Status === 'ACTIVE');
}
To get a single account by ID, use DescribeAccountCommand{AccountId}. This is cheaper than paginating all accounts when you already know the ID.
import { DescribeAccountCommand } from '@aws-sdk/client-organizations';
export async function getAccount(accountId: string) {
const response = await org.send(
new DescribeAccountCommand({ AccountId: accountId })
);
return response.Account!;
}
Creating a new member account
Account creation in Organizations is asynchronous. CreateAccountCommand returns immediately with a CreateAccountStatus containing an Id (the request ID). You must poll DescribeCreateAccountStatusCommand until the state transitions from IN_PROGRESS to SUCCEEDED or FAILED. Creation typically takes 60–90 seconds but can take up to 10 minutes during account provisioning delays.
import {
CreateAccountCommand,
DescribeCreateAccountStatusCommand,
type CreateAccountState,
} from '@aws-sdk/client-organizations';
export async function createMemberAccount(params: {
name: string;
email: string;
iamUserAccessToBilling?: 'ALLOW' | 'DENY';
roleName?: string; // cross-account role name, defaults to OrganizationAccountAccessRole
}): Promise<string> { // returns new account ID
const response = await org.send(new CreateAccountCommand({
AccountName: params.name,
Email: params.email,
// IamUserAccessToBilling: DENY prevents IAM users (not roles) from billing console
IamUserAccessToBilling: params.iamUserAccessToBilling ?? 'DENY',
// RoleName: role created in new account; management account can assume it
RoleName: params.roleName ?? 'OrganizationAccountAccessRole',
}));
const requestId = response.CreateAccountStatus!.Id!;
return waitForAccountCreation(requestId);
}
async function waitForAccountCreation(
requestId: string,
timeoutMs = 15 * 60 * 1000, // 15 min
): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = await org.send(
new DescribeCreateAccountStatusCommand({ CreateAccountRequestId: requestId })
);
const status = response.CreateAccountStatus!;
const state = status.State as CreateAccountState;
if (state === 'SUCCEEDED') {
return status.AccountId!; // the new 12-digit account ID
}
if (state === 'FAILED') {
throw new Error(
`Account creation failed: ${status.FailureReason}`
);
}
// IN_PROGRESS — keep polling
await new Promise(r => setTimeout(r, 10_000)); // poll every 10s
}
throw new Error(`Account creation timed out after ${timeoutMs}ms`);
}
The RoleName parameter creates an IAM role in the new account with the name you specify and a trust policy allowing the management account to assume it. The default name is OrganizationAccountAccessRole — keep the default unless you have a specific naming convention, as many internal tools expect this name.
Common FailureReason values:
| FailureReason | Cause | Fix |
|---|---|---|
EMAIL_ALREADY_EXISTS | The email address is already associated with an AWS account | Use a unique email, e.g. aws+prod@example.com |
GOVCLOUD_ACCOUNT_ALREADY_EXISTS | GovCloud variant already exists for this email | Use a different email |
INVALID_ADDRESS | Email format rejected | Verify email format; avoid + characters with some domains |
MISSING_PAYMENT_INSTRUMENT | Management account has no valid payment method | Add a payment method to the management account |
INTERNAL_FAILURE | AWS-side transient error | Retry after 60 seconds |
Navigating the OU hierarchy
The org hierarchy is: Root → OUs (nestable up to 5 levels) → Accounts. Use ListRootsCommand to get the root ID (always starts with r-), then walk down with ListChildrenCommand.
import {
ListRootsCommand,
ListChildrenCommand,
ListOrganizationalUnitsForParentCommand,
DescribeOrganizationalUnitCommand,
type OrganizationalUnit,
} from '@aws-sdk/client-organizations';
export async function getRootId(): Promise<string> {
const response = await org.send(new ListRootsCommand({}));
return response.Roots![0].Id!; // orgs always have exactly one root
}
// List all OUs directly under a parent (root or OU)
export async function listOUsForParent(parentId: string): Promise<OrganizationalUnit[]> {
const ous: OrganizationalUnit[] = [];
let nextToken: string | undefined;
do {
const response = await org.send(new ListOrganizationalUnitsForParentCommand({
ParentId: parentId,
NextToken: nextToken,
}));
ous.push(...(response.OrganizationalUnits ?? []));
nextToken = response.NextToken;
} while (nextToken);
return ous;
}
// List direct children (OUs and/or accounts) under a parent
export async function listChildAccounts(parentId: string): Promise<string[]> {
const accountIds: string[] = [];
let nextToken: string | undefined;
do {
const response = await org.send(new ListChildrenCommand({
ParentId: parentId,
ChildType: 'ACCOUNT',
NextToken: nextToken,
}));
accountIds.push(...(response.Children?.map(c => c.Id!) ?? []));
nextToken = response.NextToken;
} while (nextToken);
return accountIds;
}
// Recursive tree walk — returns flat list of all account IDs under a subtree
export async function getAllAccountsUnderOU(ouId: string): Promise<string[]> {
const directAccounts = await listChildAccounts(ouId);
const childOUs = await listOUsForParent(ouId);
const nestedAccounts = await Promise.all(
childOUs.map(ou => getAllAccountsUnderOU(ou.Id!))
);
return [...directAccounts, ...nestedAccounts.flat()];
}
The ListChildrenCommand ChildType parameter accepts either 'ACCOUNT' or 'ORGANIZATIONAL_UNIT' — it cannot return both in one call. For a full tree traversal, make separate calls for each type or use ListOrganizationalUnitsForParentCommand for OUs (which also returns OU details) and ListChildrenCommand with ACCOUNT for accounts.
Moving accounts between OUs
MoveAccountCommand requires the current parent ID and the destination parent ID. You first need to find the account's current parent with ListParentsCommand, then supply both to MoveAccountCommand.
import {
ListParentsCommand,
MoveAccountCommand,
CreateOrganizationalUnitCommand,
DeleteOrganizationalUnitCommand,
} from '@aws-sdk/client-organizations';
export async function getAccountParent(accountId: string): Promise<string> {
const response = await org.send(
new ListParentsCommand({ ChildId: accountId })
);
return response.Parents![0].Id!; // accounts always have exactly one parent
}
export async function moveAccountToOU(
accountId: string,
destinationOuId: string,
): Promise<void> {
const sourceParentId = await getAccountParent(accountId);
if (sourceParentId === destinationOuId) return; // already there
await org.send(new MoveAccountCommand({
AccountId: accountId,
SourceParentId: sourceParentId,
DestinationParentId: destinationOuId,
}));
}
// Create an OU under a parent
export async function createOU(name: string, parentId: string): Promise<string> {
const response = await org.send(new CreateOrganizationalUnitCommand({
Name: name,
ParentId: parentId,
}));
return response.OrganizationalUnit!.Id!; // ou-xxxx-yyyyyyyy
}
// Delete an OU — only works if it has no children (accounts or OUs)
export async function deleteOU(ouId: string): Promise<void> {
await org.send(new DeleteOrganizationalUnitCommand({ OrganizationalUnitId: ouId }));
}
An account can only have one parent at a time. Moving it removes it from the source parent's SCP scope and places it under the destination parent's SCP scope immediately. If you move an account under an OU with a restrictive SCP, that restriction takes effect within seconds.
Closing accounts
CloseAccountCommand initiates soft deletion of a member account. The account enters PENDING_CLOSURE state, then moves to SUSPENDED for 90 days before permanent deletion. During the suspension window you can reopen the account. The management account itself cannot be closed via the API.
import { CloseAccountCommand } from '@aws-sdk/client-organizations';
export async function closeAccount(accountId: string): Promise<void> {
// Will throw if accountId is the management account
// Will throw if account is already SUSPENDED or PENDING_CLOSURE
await org.send(new CloseAccountCommand({ AccountId: accountId }));
// Account now enters PENDING_CLOSURE; resources are suspended after 90 days
}
Before closing an account, ensure all resources are deleted and all data is exported — once the 90-day window passes, recovery is impossible. Closed accounts still appear in ListAccountsCommand with Status: 'SUSPENDED' until permanent deletion.
IAM permissions
All Organizations API calls must come from the management account (or a delegated admin account for specific services). The IAM policy for a typical org-management MCP tool:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"organizations:DescribeOrganization",
"organizations:ListAccounts",
"organizations:DescribeAccount",
"organizations:ListRoots",
"organizations:ListChildren",
"organizations:ListParents",
"organizations:ListOrganizationalUnitsForParent",
"organizations:DescribeOrganizationalUnit"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"organizations:CreateAccount",
"organizations:DescribeCreateAccountStatus",
"organizations:MoveAccount",
"organizations:CreateOrganizationalUnit",
"organizations:DeleteOrganizationalUnit",
"organizations:CloseAccount"
],
"Resource": "*",
"Condition": {
"StringEquals": { "aws:PrincipalAccount": "${ManagementAccountId}" }
}
}
]
}
Read-only operations (List/Describe) can be granted to member accounts via cross-account roles if needed. Write operations (CreateAccount, MoveAccount, CloseAccount) are always restricted to the management account by the Organizations service itself — the IAM condition above is a belt-and-suspenders guard.
Related pages
- AWS Organizations SCPs — CreatePolicy, AttachPolicy, evaluation logic — restrict what member accounts can do
- AWS Organizations tag policies — enforcement and GetEffectivePolicy — standardize tagging across the org
- AWS Organizations delegated administrators — SecurityHub, Config, GuardDuty — delegate org-wide service administration to member accounts
- AWS IAM for MCP servers
- AWS Security Hub for MCP servers