Guidance for AI agents (and humans) working on the AWS Partner Central Claude Desktop extension. Keep this file PII-free — it's in the public repo. Live AWS config (start URL, account, role) lives in this project's private session memory, not here.
A Claude Desktop extension (MCPB bundle) that bridges Claude to AWS's hosted Partner Central agents
MCP endpoint (https://partnercentral-agents-mcp.us-east-1.api.aws/mcp, us-east-1 only). It is a thin
local stdio MCP server that:
- Authenticates the user to AWS via IAM Identity Center (SSO device flow), caching the token like the AWS CLI.
- Auto-discovers / resolves the AWS account + role (or uses explicit config), then gets temporary role creds.
- SigV4-signs each request and forwards JSON-RPC
tools/callto the remote endpoint.
The remote endpoint exposes exactly two tools: sendMessage and getSession. Everything this extension
does is built on those. "All functionality" means the documented capabilities of those two tools (text,
file attachments, human-in-the-loop write approval), not more remote tools.
We expose 5 tools to Claude: partner_central_send_message, partner_central_respond_to_approval,
partner_central_get_session, partner_central_verify_connection, partner_central_select_account.
src/ (TypeScript, strict, ESM) compiles to server/ (gitignored; ships in the .mcpb).
index.ts— entry; bootsMcpServerover stdio; graceful shutdown.config.ts— reads/validates env from the MCPB install dialog (SSRF guard on the endpoint).services/sso-auth.ts— SSO device flow, token cache, credential resolver, SSO discovery calls.services/account-role.ts— account/role resolution (explicit → persisted → discover → elicit).services/signer.ts— SigV4.services/partner-central-client.ts— JSON-RPC client, retries, re-auth.services/response-parser.ts— normalizes agent responses (see gotchas).services/attachment-uploader.ts— uploads files to the ephemeral S3 bucket fordocumentblocks.schemas/inputs.ts/schemas/outputs.ts— Zod input + output (structuredContent) schemas.tools/index.ts— the 4 tool registrations + error mapping + elicitation wiring.tools/format.ts— markdown/json rendering, approval callout, activity trace.
npm run typecheck # tsc --noEmit
npm test # pretest builds; runs test/*.test.mjs
bash scripts/pack-mcpb.sh # production bundle -> dist/aws-partner-central.mcpb (audit gate + prune)
npx mcpb validate manifest.json # manifest schema check (manifest_version 0.3)
node scripts/smoke-tools-list.mjs # spawn server, assert tools/list (no AWS calls)Tests are plain node:assert .mjs runners (no Jest/Vitest), importing the compiled server/ —
build before running (npm test's pretest does this). Keep that style; inject mocks for AWS/fs/elicit.
- Node16 ESM → relative imports must end in
.js. - stderr-only logging (
logger) — neverconsole.log/stdout (would corrupt MCP stdio framing). Never log credentials/tokens. - No secrets or real PII anywhere committed — code, tests, this file. Use synthetic values (
123456789012,test-user@example.com). The repo is public. - Strict types, Zod validation at boundaries, small focused files, immutable updates.
- Version lives in three places that must stay in sync:
manifest.json,package.json,src/constants.ts(SERVER_VERSION).
-
Docs vs. reality. The live endpoint's response shapes differ from AWS's published docs. The parser intentionally handles both the live "stringified inner payload" form (the agent JSON is a string inside
content[0].text) and the documented inline form. Don't "simplify" it;test/response-parser*.test.mjspin both. -
JSON-RPC errors ride on HTTP 200. Classify retries by the JSON-RPC
codebeforehttpStatus(partner-central-client.ts#classifyRetry) — otherwise thehttpStatus===200branch swallows code-based decisions. Retry-32004(LIMIT_EXCEEDED) +-32603; re-auth once on-32001/HTTP 401. Rate limit:sendMessage≈ 2/min (burst 10). -
Write-approval flow is non-streaming-tricky. A
requires_approvalresponse carries only prose — the structuredtool_use_idis not in it. It lives inget_session(stateType: TOOL_REQUEST) as{tool_use_id, name, input}(snake_case — not the documentedtool_approval_requestblock). The parser recovers it from session events. Thetool_use_idchanges whenever the agent re-proposes, so fetch the latest right beforerespond_to_approval(a stale id →-32602"does not match pending tool request"). Approval also works conversationally (a natural-language follow-upsend_message). -
Account/role auto-discovery (
account-role.ts): only the SSO start URL is required. Account+role are discovered viasso:ListAccounts/sso:ListAccountRoles(the user's own access list — no extra IAM perms, no reading~/.aws/config). Single → auto; multiple → elicitation dropdown if the client supports it, else a text list. Choice persists to~/.aws-partner-central/selection-<sha1>.json(0600, non-secret). -
Elicitation support is client-dependent. Observed Claude Desktop advertises
io.modelcontextprotocol/uibut notelicitation, so the dropdown falls back to text there; it renders in Claude Code. Always capability-detect (server.server.getClientCapabilities()?.elicitation) with a text fallback. -
Endpoint is
us-east-1only;config.tsSSRF-guardsPARTNER_CENTRAL_ENDPOINTtohttps://*.api.aws. -
Blank optional config → literal
${...}placeholders (startup crash that masquerades as a connection error). Claude Desktop substitutes the LITERAL string${user_config.sso_account_id}/${user_config.sso_role_name}into the env when an optionaluser_configfield is left blank — it does not pass empty or omit the var.config.ts#readEnvmust treat an unsubstituted^${...}$as unset; otherwisevalidateAccountIdrejects the placeholder →ConfigError→process.exit(2)during config load, before the MCP handshake → Desktop reports "Could not attach / Server disconnected." This looks like connection churn but is a startup crash; the server's stderr doesn't reach Desktop's per-server log (crash precedes transport connect), so diagnose by running the bundle directly with that env. Regressed in v1.0.3 (account/role became optional), fixed in v1.0.5. Pinned bytest/config.test.mjs→ "treats unsubstituted ${...} placeholders". -
structuredContentdeliberately excludesraw. A large untypedrawblob made Claude Desktop render the tool-result disclosure blank (display-only bug; the model still receives thecontenttext, so Claude can still answer).format.ts#buildStructuredomits it on purpose — the full upstream payload is reachable viaresponse_format:'json'orget_session. Don't "helpfully" re-add it. Pinned bytest/format.test.mjs→ "structuredContent no longer carries the raw payload". -
Throttling's LIVE shape is HTTP 400
{"message":"Rate exceeded. Try again later."}— NOT the documented-32004. The endpoint throttlessendMessageto 2/min (burst 10) and other ops to 10/min (burst 20) but signals it as an HTTP 400 body, whichisRetryableHttpStatustreats as fatal — so throttles used to surface raw to the model (confirmed in the ACE opportunities reconciliation co-work run: 18 throttles, all surfaced, forcing manual pauses).partner-central-client.ts#isThrottleErrornow recognizes both the-32004code AND any HTTP 429 / 4xx whose body matches/rate exceeded|throttl|too many requests/i— keyed on the BODY, not the 400 status, so a genuine bad-request 400 stays non-retryable. Throttle retries use a deeper backoff (THROTTLE_BASE_DELAY_MS/THROTTLE_MAX_DELAY_MS, ~4–20s × 3 attempts ≈ up to ~30s) to span the refill; transient/5xx keep the short backoff. Pinned bytest/client-retry.test.mjs. -
Tool results are capped to
CHARACTER_LIMIT(40k), applied to the COMBINEDtext+structuredContent. The client (Claude Desktop / co-work host loop) rejects results over its ~25k-token cap with "exceeds maximum allowed tokens" and saves them to a temp file the sandboxed agent often can't read — so a largeget_session(esp.response_format:'json', which dumps the wholeraw) silently dead-ends (seen in the same co-work run).format.tstrims to fit: capsstructuredContent.eventstoMAX_STRUCTURED_EVENTS(20), then drops events / truncates text as needed, and always preservesstatus+approval_requests(the approval loop'stool_use_id). Don't raiseCHARACTER_LIMITback to 100k. Pinned bytest/format.test.mjs. -
The agent's stage-readiness "validation" is ADVISORY, not a hard gate — writes are partner-initiated. The remote
deal_progression_advisor/validate_stage_transitioncan returnis_valid:falsewith reasons like "AWS Launch Status REQUIRED — AWS hasn't marked it Launched", "no marketplace offer", "no customer acceptance". Those are soft heuristics; the Selling API enforces the real constraints and frequently accepts the write anyway. Empirically confirmed 2026-06-03: a realFor Visibility Onlyopportunity was progressed Qualified→Launched and the Selling API returnedsuccess:truewhile the advisor still saidis_valid:false(its rule #1 just checks whetherAWS.LifeCycle.Stageis null — expected for visibility-only deals — so it's structurally wrong to treat as a gate). Two consequences: (a) the partner drivesStage(incl. Launched/closed-won); there is no "AWS must launch first" step. (b) Phrasing matters — "is this transition valid?" makes the agent editorialize and refuse; an instruction to EXECUTE ("set Stage to Launched and proceed") makes it build the realupdate_opportunity_enhancedwrite. The extension only forwards — this lives as guidance in thesend_messagetool description; do not add a code gate. -
Hosts STRIP
requiredfrom the advertised input schema — so no tool may hard-depend on a parameter arriving. Diagnosed 2026-08-14 from a Cowork bug report:get_sessionfailed on every call with-32602 Input validation error: … path ["session_id"] … "Required". That string is emitted by our own server (@modelcontextprotocol/sdk/server/mcp.js#validateToolInput), not the bridge — the arguments genuinely arrived withoutsession_id. Two clues pinned the cause: the schema is.strict(), so a renamed key would have added anunrecognized_keysissue (the report had only the one issue ⇒ nothing unexpected was sent,session_idwas simply absent), and the schema surfaced to the calling model was{properties:{catalog,response_format,session_id},type:"object"}— norequired, and nopattern/minLength/maxLength, with siblingdescriptions dropped. The server advertises all of those correctly (verify withscripts/-styletools/listdump), so the loss is host-side schema normalisation under a size budget. Withrequiredgone,session_idreads as optional and the model omits it. Same root cause for the "sessions have no memory" report: an omitted (genuinely optional)session_idonsend_messagesilently starts a NEW session. Not an AWS-side change — the remote honourssession_idfine (proved live: twosendMessages in one Sandbox session round-tripped "BANANA", andgetSessionwith an explicit id returned a proper remote "not found"). Fix (v1.0.10):session_id(andrespond_to_approval'stool_use_id) are.optional()in Zod so the SDK cannot dead-end the call before the handler runs; the handler then resolves them —session-memory.tsremembers the latest session per catalog, and a missingtool_use_idis read back from the session. Format checks still apply when a value IS supplied, and an inferred session is always disclosed (session_id_inferred). Don't "tidy" these back to required — SDK-level enforcement is exactly what fails unrecoverably here. Pinned bytest/session-resilience.test.mjs. -
The approval
tool_use_idis now recovered server-side —get_sessionis off the critical path. The reported impact of #12 was that writes were unreachable:requires_approvalcarries only prose, so the caller had to fetchtool_use_idviaget_session, which was the broken tool.send_message/respond_to_approvalnow callgetSessioninternally on arequires_approvalreply and merge the pending request intoapproval_requests[], so the first response carries the id (also the freshest possible read — gotcha #3's id changes on re-propose, so omittingtool_use_idis the fix for a stale "does not match pending tool request", not a risk). Recovery is best-effort (a throttled/failed lookup must never turn a usable reply into an error) and only runs for approvals, andrespond_to_approvalrefuses to guess when >1 write is pending — it lists them instead. -
SSO region ≠ Partner Central region — and
AWS_REGIONmust be ignored. From GitHub issue #2 (mschmidt77, eu-central-1): a user whose Identity Center lives outside us-east-1 could not sign in; forcing the region inconstants.jsfixed sign-in and then broke Partner Central. There are two independent regions andPartnerCentralConfigalready modelled both (config.regionvsconfig.sso.region) —loadConfigwas just assigning one value to both.
config.sso.region←AWS_SSO_REGION(new optionalsso_regioninstall field, defaultus-east-1). Consumed only bysso-auth.ts(SSOOIDC device flow,ListAccounts/ListAccountRoles/GetRoleCredentials).config.region← derived from the endpoint host (regionFromEndpoint), never from env. This is the SigV4 signing region and MUST match the endpoint. Deliberately no longer readsAWS_REGION: that var is not a manifest field, so it arrives from the ambient process env — anyone withAWS_REGIONexported (common for AWS users) previously had BOTH regions silently moved off us-east-1, breaking signing. Don't "restore" it as a fallback.- Token-cache region guard (
isCachedTokenUsable): the cache is keyed onsha1(startUrl)ALONE and is shared with the AWS CLI, so an entry can be from another SSO region; an SSO token is only valid in its issuing region, so a mismatch must be a cache MISS or it fails later insideListAccountsas a baffling auth error. verify_connectionand the startup log print both regions so this misconfiguration self-diagnoses. Verification without an eu-central-1 account:RegisterClientis unauthenticated (succeeds live againstoidc.eu-central-1.amazonaws.com) andListAccountsreturns a region-localUnauthorizedException, proving real regional routing.test/sso-region.test.mjspins host resolution hermetically (records the host in the SDKbuildstep, then aborts — no creds, no network). Still unverified end-to-end:CreateToken+GetRoleCredentialsagainst a directory genuinely in another region — needs the reporter to confirm.
- We must NOT advertise a
$schemadialect — draft-07 makes strict hosts refuse every tool. Diagnosed 2026-08-14 after a total outage: all 5 tools failed with "Tool 'partner_central_verify_connection' has an invalid outputSchema: JSON Schema declares an unsupported dialect ($schema: draft-07). The default validator supports JSON Schema 2020-12 only". Cause: we use Zod v3, so the SDK'stoJsonSchemaCompattakes its v3 branch and callszodToJsonSchemawith notarget, defaulting to draft-07 and stamping"$schema": "http://json-schema.org/draft-07/schema#"onto EVERY input and output schema. Hosts that validate tool schemas with an Ajv 2020-12 instance reject that dialect before the handler runs, so it takes out all tools at once. Upgrading the SDK does not fix it — it hardcodes draft-7 on the Zod v4 path too.
- Fix (v1.0.12):
src/schema-dialect.tsstrips$schemaat the transport boundary (withCompatibleSchemaDialectwrapstransport.send), so it covers every response the SDK generates and survives SDK upgrades without touching SDK internals. - Omit
$schema; do NOT declare 2020-12. Omitting lets each host apply its own default, and our schemas only use keywords identical in both dialects (type/properties/required/enum/default/ description/minLength/maxLength/pattern/items/maxItems/additionalProperties). Declaring 2020-12 would break the mirror-image host that only understands draft-07. Verified no$ref/definitionsare emitted, so nothing depends on#/definitionsvs#/$defs. - Latent since v1.0.0, not a regression: same SDK + zod pins in v1.0.9. It surfaced only when the
device bridge's tool-schema refresh path ran Ajv validation (the initially cached tool list did
not), which is why
verify_connectionworked twice and then broke mid-session with no reinstall. - Why testing missed it:
npm testcalls handlers directly, the hand-rolled stdio harnesses never validated schemas, and Claude Code's client does not run this check.scripts/smoke-tools-list.mjsnow fails if any tool advertises a dialect — that is the guard. Pinned bytest/schema-dialect.test.mjs. Lesson: validating against the client you happen to have is not validating; test through the surface that actually failed.
- Reinstalling the
.mcpbdoes NOT reload the running server — Desktop keeps the old process. Cost real debugging time: after installing v1.0.11, the files on disk were v1.0.11 while the process answering requests was still v1.0.9, so a fixed bug "reproduced" verbatim. Node caches modules at load time, so the old code serves every call until the process restarts. Fingerprint which build is LIVE (never trust the on-disk manifest):initialize→serverInfo.version, or the shape ofverify_connectionoutput (two region lines ⇒ ≥v1.0.11), or the version banner in~/Library/Logs/Claude/mcp-server-AWS Partner Central.log. To reload: toggle the extension off/on in Settings → Extensions, or fully quit Claude Desktop (Cmd+Q); a Cowork/bridge session must also reconnect. Tell users to restart after upgrading, or they will report the bug you just fixed.
- The extension is usually connected to the dev session as
mcp__AWS_Partner_Central__*— but that's the installed build (often older). To test the current build, spawnnode server/index.jsover stdio with env vars and do the MCP handshake (seescripts/smoke-tools-list.mjs/ the/tmp/*.mjsharness pattern). - SSO token cache:
~/.aws/sso/cache/<sha1(startUrl)>.json(~8h). If expired, spawning triggers an interactive browser device flow — fine with the user present; don't trigger it unprompted. Sandboxcatalog = safe test data;AWScatalog = real production. Use Sandbox for tests.- Safety boundaries (the classifier enforces these — don't work around them): never extract the raw SSO token to drive the AWS CLI directly; never advance agent-fabricated writes into production. Read-only prod checks and Sandbox writes are OK. Production writes are the user's action (their data, their approval).
- Repo:
github.com/customd/aws-partner-central-mcp. Pushing to thecustomdorg needs themoacodegh account (the work account can't create/push there) —gh auth switch --user moacodefirst (see memory). - Bump version in the three files above,
npm test,bash scripts/pack-mcpb.sh, thengh release create vX.Y.Z dist/aws-partner-central.mcpb -R customd/aws-partner-central-mcp --latest. - Docs: README (users), PRIVACY.md (required for directory; keep accurate re: files written & APIs called), DISTRIBUTION.md (build + submit process), SUBMISSION.md (paste-ready directory-form packet), TESTING.md (Sandbox acceptance test + live results).
- Directory submission is the user's manual step (Google form, their account). Local desktop extensions are eligible; OAuth-callback requirements do not apply (auth is AWS SSO, not Claude OAuth).
- Latest tag: v1.0.9 (on
main; the earlier "v1.0.8/v1.0.9 pending, untagged" note was stale —git tagshows v1.0.9 exists). Latest release: v1.0.12. NOTE v1.0.11 is effectively broken on strict hosts (gotcha #15) — always point users at v1.0.12 or later.- v1.0.12 (schema-dialect hotfix — gotcha #15): stops advertising
"$schema": draft-07, which made Ajv-2020-12 hosts refuse all 5 tools before any handler ran. Latent since v1.0.0.scripts/smoke-tools-list.mjsnow fails the build if any tool declares a dialect. Pinned bytest/schema-dialect.test.mjs. - v1.0.11 ships TWO independent fixes as one tag — they are separate commits (v1.0.10 was never tagged on its own), so read both bullets below when writing release notes.
- v1.0.11 (SSO region split — gotcha #14, GitHub issue #2):
AWS_SSO_REGION/sso_regioninstall field separates the sign-in region from the us-east-1 signing region;AWS_REGIONis now ignored; token-cache region guard; both regions shown byverify_connection. Pinned bytest/sso-region.test.mjs. The non-us-east-1 SSO path is unconfirmed end-to-end (no test directory outside us-east-1) — the reporter was asked on issue #2 to verify. It cannot regress us-east-1 users:sso_regiondefaults tous-east-1and the signing region is now derived rather than env-driven. - v1.0.10 (session-id resilience, from the Cowork
get_sessionbug report — gotchas #12/#13): tools no longer hard-depend onsession_idarriving (hosts striprequired);session-memory.tsresolves the latest session per catalog; the approvaltool_use_idis recovered server-side soget_sessionis off the write path. Handlers factored into exportedrunSendMessage/runGetSession/runRespondToApprovalfor testability (same pattern asrunSelectAccount). - v1.0.8: clickable opportunity console links in replies, friendly tool labels (
annotations.title), a status emoji, and removesrawfromstructuredContent(blank-disclosure fix). - v1.0.9 (scale-resilience, from the ACE opportunities reconciliation diagnosis): recognizes the live HTTP 400 "Rate exceeded" throttle (not just
-32004) with a deeper throttle backoff (gotcha #9), and caps tool-result size to keep largeget_session/json payloads under the client's token cap (gotcha #10).gh release create vX.Y.Z …(moacode account) is the user's manual step.
- v1.0.12 (schema-dialect hotfix — gotcha #15): stops advertising
- Known follow-ups: verify the prod test opportunity O2100000 was actually closed; Windows install smoke test (only macOS verified); directory submission pending the user.
- "AWS needs to launch" was the advisor hallucinating (see gotcha #11) — proven by doing it. On 2026-06-03
a real
For Visibility Onlyopportunity was progressed Qualified→Launched via the agent and the Selling API returnedsuccess:true, even thoughvalidate_stage_transitionreportedis_valid:false. So the reconciliation's other launch/closed-won items can be launched directly by the partner (instruct the agent to EXECUTE — "set Stage to Launched and proceed" — don't ask "is it valid?"); they do not need AWS coordination.send_message's description now carries this "Writes & stage progression" guidance.