Machine-to-machine MCP authentication
Let scheduled jobs, backend agents, and CI automation call a deployed MCP server with a least-privilege service principal.
OAuth Client Credentials is currently Preview. Existing deployments and interactive OAuth clients continue to work as before. Adding machine access requires no app-code change and no redeploy.
Suppose a reporting worker runs every morning and calls a read-only generate_report tool. No person is
present to open a browser or approve an OAuth consent screen. Give that workload its own organization-owned
service principal instead of sharing a person's login.
A service principal belongs to your Noodle Seed organization. Each grant authorizes it for one app and one environment with an explicit OAuth scope ceiling. Its credential is exchanged for a short-lived, resource-bound access token that works only at the exact deployed MCP URL.
When to use machine authentication
Use a service principal when software calls MCP without a person present:
| Scenario | Recommended authentication |
|---|---|
| A scheduled reporting or synchronization job | Service principal |
| A backend agent responding to queue events | Service principal |
| CI/CD calling a validation or deployment tool | Service principal |
| A server-to-server integration | Service principal |
| ChatGPT or Claude with a person present | Interactive OAuth, not a service principal |
| A customer using your product through an MCP client | Customer auth |
| Noodle calling a downstream business API | A separately managed connector credential |
Create a separate principal for each workload. That makes its permissions, activity, rotation, and revocation independent from every other automation.
How the flow works
The service-principal credential proves which workload is calling. The grant limits where it may call and which scopes it may request. The tool's own authorization remains in force.
An inbound MCP bearer token is never forwarded to a business backend. If the tool calls another API, Noodle resolves that API's connector credential separately through the credential broker.
Before you start
You need:
- An existing deployed app and environment. This guide uses
acme / reporting / prod. - The exact deployed MCP URL, such as
https://acme.cloud.noodleseed.dev/reporting/mcp. - A read-only tool to verify. This guide uses
generate_reportwith the scopereports.read. - Organization-owner access to manage service principals.
- Node.js 24 or newer for the example worker.
- A secrets manager, workload identity store, or equivalent protected location for the private credential.
If the tool declares requiredScopes, use those exact scope tokens. A service principal has no human roles,
so a tool that also requires an allowed role is not eligible for machine access.
1. Create the service principal
Open the Noodle Console, choose the organization, then open Organization settings and Machine access.
Select Create service principal, enter Reporting worker, and create it. Open the new
OAuth service principal and copy its principal ID. The principal ID is also the OAuth client_id your
worker will use.
The identity belongs to the organization rather than one app. It cannot call anything until you create an app/environment grant and an active credential.
2. Grant access to one deployment target
In Grants, select:
| Field | Example value |
|---|---|
| App | reporting |
| Environment | prod |
| OAuth scope tokens | reports.read |
Select Create grant. Noodle does not infer wildcard scopes. If you leave the scope list empty, the
principal can reach only otherwise-eligible tools that do not declare requiredScopes.
A principal can have only one active grant for the same app and environment. To change that grant, revoke it and create its replacement. That produces a deliberate interruption, so plan scope changes separately from credential rotation.
3. Add a credential
The Credentials section offers two choices:
| Method | Use |
|---|---|
| Public key | Recommended. The worker signs a short-lived private_key_jwt; Noodle stores only the public JWK. |
| Client secret | Fallback for automation platforms that support only client_secret_basic. The secret is displayed once. |
Recommended: register a public key
Generate the key inside your secrets-management workflow when possible. For a local Node.js 24 setup, save
this as generate-machine-key.mjs and run it from a private working directory:
import { generateKeyPairSync, randomUUID } from 'node:crypto'
import { writeFileSync } from 'node:fs'
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
const kid = randomUUID()
const publicJwk = {
...publicKey.export({ format: 'jwk' }),
alg: 'RS256',
use: 'sig',
key_ops: ['verify'],
kid,
}
const privateJwk = {
...privateKey.export({ format: 'jwk' }),
alg: 'RS256',
use: 'sig',
key_ops: ['sign'],
kid,
}
writeFileSync('machine-auth-public.jwk', `${JSON.stringify(publicJwk, null, 2)}\n`, {
mode: 0o644,
})
writeFileSync('machine-auth-private.jwk', `${JSON.stringify(privateJwk, null, 2)}\n`, {
mode: 0o600,
})In the Console, enter the label primary, optionally set an expiry, choose
machine-auth-public.jwk, and select Create public key. The Console accepts RS256/RSA and ES256/P-256
public JWKs. It rejects private or symmetric key material.
Upload machine-auth-private.jwk to the worker's secrets manager. Confirm that the stored secret is usable,
then remove the local private copy. Never upload the private file to Noodle or commit either generated file
to source control.
Fallback: create a client secret
Choose Client secret, use a label such as primary, optionally set an expiry, and select
Create client secret. Copy the client ID and secret directly into your secrets manager. The Console shows
the secret once and cannot retrieve it later.
Client secrets are long-lived credentials. Prefer a public key when your automation platform can sign JWT assertions.
CLI equivalent
You can provision the same identity from the CLI. Create the principal first and copy principalId from the
JSON response:
noodle auth service-principals create "Reporting worker" --org acme --jsonThen create the exact grant and add only the public key:
noodle auth service-principals grant <principal-id> \
--org acme \
--app reporting \
--env prod \
--scope reports.read
noodle auth service-principals add-jwk <principal-id> \
--org acme \
--label primary \
--file ./machine-auth-public.jwkIf public-key authentication is unavailable, create a fallback secret. Its value is returned once:
noodle auth service-principals create-secret <principal-id> \
--org acme \
--label fallbackSee the noodle auth reference for expiry options, JSON output, and the complete
service-principal command set.
4. Call MCP from the worker
Install the official MCP v2 client in the worker project:
pnpm add @modelcontextprotocol/client@2Save the following as reporting-worker.mjs. It loads configuration at runtime, negotiates the supported
MCP era automatically, obtains an access token, and calls the read-only tool:
import { readFile } from 'node:fs/promises'
import {
Client,
PrivateKeyJwtProvider,
StreamableHTTPClientTransport,
} from '@modelcontextprotocol/client'
const mcpUrl = required('MCP_URL')
const issuer = required('MCP_ISSUER')
const clientId = required('MCP_CLIENT_ID')
const privateKey = JSON.parse(await readFile(required('MCP_PRIVATE_JWK_FILE'), 'utf8'))
const client = new Client(
{ name: 'reporting-worker', version: '1.0.0' },
{ capabilities: {}, versionNegotiation: { mode: 'auto' } },
)
await client.connect(
new StreamableHTTPClientTransport(new URL(mcpUrl), {
authProvider: new PrivateKeyJwtProvider({
clientId,
privateKey,
algorithm: 'RS256',
expectedIssuer: issuer,
scope: 'reports.read',
}),
}),
)
try {
const result = await client.callTool({
name: 'generate_report',
arguments: { period: 'previous_day' },
})
if (result.isError) throw new Error('generate_report returned an error')
console.log('Report generated successfully')
} finally {
await client.close()
}
function required(name) {
const value = process.env[name]
if (!value) throw new Error(`${name} is required`)
return value
}Configure the worker through its deployment platform rather than a checked-in .env file:
MCP_URL=https://acme.cloud.noodleseed.dev/reporting/mcp
MCP_ISSUER=https://cloud.noodleseed.dev
MCP_CLIENT_ID=<principal-id>
MCP_PRIVATE_JWK_FILE=/run/secrets/machine-auth-private.jwkRun the worker with node reporting-worker.mjs. Replace the example tool name, arguments, and scope with the
read-only tool from your deployment.
What the SDK sends
The auth provider discovers the Noodle authorization server, then sends a token request containing:
grant_type=client_credentialsresource=https://acme.cloud.noodleseed.dev/reporting/mcpscope=reports.read- A short-lived
private_key_jwtassertion signed by the registered private key
Noodle verifies the credential and exact grant, then returns a bearer token with a ten-minute lifetime and no refresh token. The SDK obtains another token when needed. The token is accepted only for the exact MCP resource URL, and the gateway rechecks live revocation before dispatching a call.
Use a client secret when required
If the workload platform cannot sign JWT assertions, replace the auth provider with
ClientCredentialsProvider. Read the one-time secret from your secrets manager at runtime:
import { ClientCredentialsProvider } from '@modelcontextprotocol/client'
const authProvider = new ClientCredentialsProvider({
clientId: required('MCP_CLIENT_ID'),
clientSecret: required('MCP_CLIENT_SECRET'),
expectedIssuer: required('MCP_ISSUER'),
scope: 'reports.read',
})Pass authProvider to StreamableHTTPClientTransport as in the complete example. This uses
client_secret_basic; it does not place the secret in the request body. Never hard-code or print the secret.
5. Verify before enabling the schedule
Use the repository smoke against an explicitly read-only tool before letting a scheduler or event consumer run unattended:
pnpm smoke:mcp-client-credentials -- \
--url https://acme.cloud.noodleseed.dev/reporting/mcp \
--issuer https://cloud.noodleseed.dev \
--client-id <principal-id> \
--private-jwk-file ./machine-auth-private.jwk \
--scope reports.read \
--tool generate_report \
--args-json '{"period":"previous_day"}'The private JWK file must have 0600 permissions. For the fallback secret path, put the secret in a named
environment variable and replace --private-jwk-file with --client-secret-env MCP_CLIENT_SECRET. The smoke
does not accept a secret value on the command line and does not print credentials, access tokens, tool
arguments, or tool output.
Rotate credentials without downtime
Credentials can overlap. Rotate one without interrupting the worker:
- Add a new public key or client secret with a distinct label.
- Update the worker's secrets manager and restart or reload the worker.
- Run the read-only smoke with the new credential.
- Revoke the old credential only after the new path succeeds.
For CLI-managed public keys, add the replacement with noodle auth service-principals add-jwk, then revoke
the old credential:
noodle auth service-principals revoke-credential <principal-id> <credential-id> \
--org acme \
--yesUse the Console's Replace action for the same overlap-first sequence. The old credential remains active until you explicitly confirm revocation.
Revoke access
Choose the narrowest action that matches the incident or lifecycle event:
| Action | Effect |
|---|---|
| Revoke one credential | Stops that key or secret while other credentials and the grant remain active. |
| Revoke one grant | Stops this principal from calling that app/environment. |
| Revoke the service principal | Stops all of the principal's grants and credentials. This cannot be undone. |
noodle auth service-principals revoke-credential <principal-id> <credential-id> \
--org acme \
--yes
noodle auth service-principals revoke-grant <principal-id> <grant-id> \
--org acme \
--yes
noodle auth service-principals revoke <principal-id> \
--org acme \
--yesRevocation blocks future token exchanges and already-issued machine tokens. Existing lifecycle metadata remains visible in Recent lifecycle so organization members can understand what changed.
Troubleshooting
| Symptom | What to check |
|---|---|
invalid_client | The principal and credential are active, the correct private key or secret is loaded, the assertion uses RS256 or ES256, and the issuer is exact. This error is deliberately generic. |
invalid_target | MCP_URL is the exact currently served URL and the principal has an active grant for its app and environment. Do not add a query, fragment, or alternate hostname. |
invalid_scope | Every requested scope is unique and falls within the active grant's scope ceiling. |
| The tool is not advertised | Check the tool name, required scopes, read-only annotation for the smoke, and any role requirement. |
| A role-gated tool is unavailable | Service principals have scopes but no human roles. Use a machine-eligible tool or change the tool's deliberate authorization design. |
| A previously working token is denied | Check Recent lifecycle for principal, grant, or credential revocation and verify which app/environment the worker targets. |
Authentication errors do not reveal whether a principal or credential record exists. Use the Console or
noodle auth service-principals show <principal-id> --org acme while signed in as an organization member to
inspect safe lifecycle metadata.
Security checklist
- Use one principal per workload rather than sharing one identity across unrelated jobs.
- Grant one exact app/environment and only the scopes the worker needs.
- Prefer a public key and a short-lived
private_key_jwtassertion over a shared client secret. - Keep private keys and secrets in a secrets manager; never commit them or put them in command arguments.
- Set
expectedIssuerand use the exact resource URL to prevent credential and token misrouting. - Cache access tokens only until their expiry and obtain a new token instead of expecting a refresh token.
- Add and verify a replacement credential before revoking the old one.
- Never log credentials, assertions, access tokens, request headers, or token responses.
- Never forward an MCP bearer token to a connector or business backend.
- Revoke a credential, grant, or principal promptly when its workload is retired or compromised.
The flow follows the official MCP OAuth Client Credentials extension, while Noodle Seed adds organization ownership, exact deployment grants, live revocation, and tenant-safe operational evidence around it.