diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallback.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallback.java
new file mode 100644
index 00000000..f3445097
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallback.java
@@ -0,0 +1,112 @@
+package vip.mate.tool.mcp.runtime;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.ai.tool.metadata.ToolMetadata;
+
+/**
+ * Wraps an MCP {@link ToolCallback} for a trusted server (opt-in via
+ * {@link McpIdentityForwardProperties}) and injects the caller's identity into
+ * the call arguments, so the STDIO MCP server can forward it on-behalf-of to its
+ * downstream REST backend.
+ *
+ *
What gets injected is decided by {@link McpIdentityForwardService}: either
+ * the plaintext username (under {@code __mateclaw_user__}) or a short-lived
+ * signed JWT (under {@code __mateclaw_token__}).
+ *
+ *
STDIO has no per-request header channel and the subprocess is shared by all
+ * users, so identity must ride in-band, per call. The identity is
+ * derived from the trusted server-side {@link ToolContext}, never from the model
+ * — any LLM-supplied value of the reserved key is overwritten, so the model
+ * cannot spoof identity.
+ *
+ *
The wrapper is transparent in every other respect: tool definition,
+ * metadata, schema, and (when there is no identity to inject) the call itself are
+ * forwarded verbatim. It sits inside {@link PrefixedNameToolCallback}
+ * so name prefixing and return-direct detection still see the raw delegate.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+public final class IdentityForwardingToolCallback implements ToolCallback {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private final ToolCallback delegate;
+ private final McpIdentityForwardService identityService;
+ private final String audience;
+
+ public IdentityForwardingToolCallback(ToolCallback delegate,
+ McpIdentityForwardService identityService,
+ String audience) {
+ if (delegate == null || identityService == null) {
+ throw new IllegalArgumentException("delegate and identityService must not be null");
+ }
+ this.delegate = delegate;
+ this.identityService = identityService;
+ this.audience = audience;
+ }
+
+ @Override
+ public ToolDefinition getToolDefinition() {
+ return delegate.getToolDefinition();
+ }
+
+ @Override
+ public ToolMetadata getToolMetadata() {
+ return delegate.getToolMetadata();
+ }
+
+ @Override
+ public String call(String toolInput) {
+ return delegate.call(inject(toolInput, null));
+ }
+
+ @Override
+ public String call(String toolInput, ToolContext toolContext) {
+ return delegate.call(inject(toolInput, toolContext), toolContext);
+ }
+
+ /** Exposed for diagnostic / wrapping detection. */
+ public ToolCallback getDelegate() {
+ return delegate;
+ }
+
+ private String inject(String toolInput, ToolContext toolContext) {
+ return identityService.resolve(toolContext, audience)
+ .map(i -> withClaim(toolInput, i.key(), i.value()))
+ .orElse(toolInput);
+ }
+
+ /**
+ * Merge {@code (key, value)} into the JSON arguments, overwriting any value
+ * the model supplied for that key. Returns the input unchanged when the
+ * arguments are not a JSON object (nothing to merge into) or are malformed
+ * (let the call surface the error rather than mask it by rewriting).
+ */
+ static String withClaim(String toolInput, String key, String value) {
+ try {
+ ObjectNode node;
+ if (toolInput == null || toolInput.isBlank()) {
+ node = MAPPER.createObjectNode();
+ } else {
+ JsonNode parsed = MAPPER.readTree(toolInput);
+ if (!parsed.isObject()) {
+ log.warn("[McpIdentity] tool input is not a JSON object; forwarding without identity");
+ return toolInput;
+ }
+ node = (ObjectNode) parsed;
+ }
+ node.put(key, value);
+ return MAPPER.writeValueAsString(node);
+ } catch (Exception e) {
+ log.warn("[McpIdentity] failed to inject identity into tool input: {}", e.getMessage());
+ return toolInput;
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java
index a83024d7..f4187f48 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java
@@ -65,8 +65,15 @@ public class McpClientManager {
private final ApplicationEventPublisher eventPublisher;
- public McpClientManager(ApplicationEventPublisher eventPublisher) {
+ private final McpIdentityForwardService identityForwardService;
+
+ /** serverId -> server name, captured at build time for identity-forward opt-in matching. */
+ private final ConcurrentHashMap serverNames = new ConcurrentHashMap<>();
+
+ public McpClientManager(ApplicationEventPublisher eventPublisher,
+ McpIdentityForwardService identityForwardService) {
this.eventPublisher = eventPublisher;
+ this.identityForwardService = identityForwardService;
}
/** serverId -> connection result info */
@@ -190,7 +197,11 @@ public class McpClientManager {
SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(entry.getValue());
ToolCallback[] cbs = provider.getToolCallbacks();
if (cbs != null && cbs.length > 0) {
- List wrapped = wrapServerCallbacks(serverId, cbs);
+ String serverName = serverNames.get(serverId);
+ McpIdentityForwardService idSvc =
+ identityForwardService.forwardsTo(serverId, serverName) ? identityForwardService : null;
+ String audience = idSvc != null ? identityForwardService.audienceFor(serverId, serverName) : null;
+ List wrapped = wrapServerCallbacks(serverId, cbs, idSvc, audience);
lastGoodCallbacks.put(serverId, wrapped);
allCallbacks.addAll(wrapped);
continue;
@@ -242,6 +253,19 @@ public class McpClientManager {
* real {@link McpSyncClient}.
*/
static List wrapServerCallbacks(long serverId, ToolCallback[] cbs) {
+ return wrapServerCallbacks(serverId, cbs, null, null);
+ }
+
+ /**
+ * @param identitySvc when non-null, each callback is additionally wrapped in
+ * {@link IdentityForwardingToolCallback} (inside the prefix wrapper) so
+ * the caller's identity rides along with the call. Driven by
+ * {@link McpIdentityForwardService} opt-in per server; {@code null}
+ * means this server does not forward identity.
+ * @param audience the token audience for this server (ignored in plaintext mode).
+ */
+ static List wrapServerCallbacks(long serverId, ToolCallback[] cbs,
+ McpIdentityForwardService identitySvc, String audience) {
List rawNames = new ArrayList<>(cbs.length);
for (ToolCallback cb : cbs) {
rawNames.add(cb.getToolDefinition() != null ? cb.getToolDefinition().name() : null);
@@ -267,7 +291,10 @@ public class McpClientManager {
serverId, raw, d.prefixedName(), d.unavailableReason());
continue;
}
- out.add(new PrefixedNameToolCallback(d.prefixedName(), cb));
+ ToolCallback inner = identitySvc != null
+ ? new IdentityForwardingToolCallback(cb, identitySvc, audience)
+ : cb;
+ out.add(new PrefixedNameToolCallback(d.prefixedName(), inner));
}
return out;
}
@@ -362,6 +389,11 @@ public class McpClientManager {
* side-effect free.
*/
private McpSyncClient buildClient(McpServerEntity server, boolean managed) {
+ if (server.getId() != null && server.getName() != null) {
+ // Remember the name so identity-forward opt-in can be matched by name
+ // (not just numeric id) when callbacks are wrapped.
+ serverNames.put(server.getId(), server.getName());
+ }
McpClientTransport transport = switch (server.getTransport()) {
case "stdio" -> buildStdioTransport(server, managed);
case "sse" -> buildSseTransport(server);
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardProperties.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardProperties.java
new file mode 100644
index 00000000..32979408
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardProperties.java
@@ -0,0 +1,151 @@
+package vip.mate.tool.mcp.runtime;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Opt-in configuration for forwarding the calling user's identity to MCP servers.
+ *
+ * When a server is listed in {@link #servers}, every tool call routed to it is
+ * wrapped by {@link IdentityForwardingToolCallback}, which injects the caller's
+ * identity into the call arguments. Two trust models:
+ *
+ *
+ * - Plaintext (default, {@code token.enabled=false}) — injects the
+ * username under {@link #USER_ARG}. The REST backend trusts whatever the
+ * MCP service forwards; only fits a trusted network where the backend
+ * authenticates the MCP service by API key.
+ * - Signed token ({@code token.enabled=true}) — injects a short-lived
+ * RS256 JWT under {@link #TOKEN_ARG} (sub=user, aud=server, short exp),
+ * minted by {@link McpIdentityForwardService} with MateClaw's private key.
+ * The REST backend verifies it with the public key, so it trusts
+ * the signature — not the MCP service, the Python script, or the transport.
+ * This is the cross-trust-boundary baseline.
+ *
+ *
+ * Why opt-in, and per server. A single STDIO subprocess is shared by
+ * every user (the client pool is keyed by server id), so identity can only
+ * travel in-band per call — never via the process environment, which is
+ * fixed at spawn. And forwarding identity to every MCP server would leak
+ * it to any third-party server an operator adds. So it is off by default and
+ * enabled per trusted server.
+ *
+ *
Configuration ({@code application.yml}):
+ *
+ * mateclaw:
+ * mcp:
+ * identity-forward:
+ * servers:
+ * - my-internal-api # MCP server name (mate_mcp_server) or numeric id
+ * token:
+ * enabled: true # off => plaintext username (back-compat)
+ * issuer: mateclaw
+ * ttl-seconds: 60
+ * key-id: mateclaw-mcp-1
+ * private-key-pem: ${MCP_IDFWD_PRIVATE_KEY_PEM:} # PKCS#8 PEM, RS256
+ * audiences: # optional name/id -> aud; default aud = server name
+ * my-internal-api: my-internal-api
+ *
+ *
+ * @author MateClaw Team
+ */
+@Component
+@ConfigurationProperties(prefix = "mateclaw.mcp.identity-forward")
+public class McpIdentityForwardProperties {
+
+ /**
+ * Reserved tool-argument key carrying the plaintext username (plaintext
+ * trust model). Collision-unlikely with real tool parameters; the MCP
+ * server reads and strips it.
+ */
+ public static final String USER_ARG = "__mateclaw_user__";
+
+ /**
+ * Reserved tool-argument key carrying the signed JWT (token trust model).
+ * The MCP server forwards it as a bearer token; the REST backend verifies.
+ */
+ public static final String TOKEN_ARG = "__mateclaw_token__";
+
+ /** Server names (as in mate_mcp_server) or numeric ids that opt in. */
+ private Set servers = Collections.emptySet();
+
+ private Token token = new Token();
+
+ public Set getServers() {
+ return servers;
+ }
+
+ public void setServers(Set servers) {
+ this.servers = servers != null ? new LinkedHashSet<>(servers) : Collections.emptySet();
+ }
+
+ public Token getToken() {
+ return token;
+ }
+
+ public void setToken(Token token) {
+ this.token = token != null ? token : new Token();
+ }
+
+ /**
+ * @return {@code true} iff the configured set contains either the server's
+ * numeric id (as a string) or its name. Either argument may be
+ * {@code null}; the other is still checked.
+ */
+ public boolean forwardsTo(Long serverId, String serverName) {
+ if (servers.isEmpty()) {
+ return false;
+ }
+ if (serverId != null && servers.contains(String.valueOf(serverId))) {
+ return true;
+ }
+ return serverName != null && servers.contains(serverName);
+ }
+
+ /**
+ * Audience claim for a server's minted tokens: an explicit mapping (by name
+ * or id) when configured, otherwise the server name (or id as string). Lets
+ * the backend reject a token minted for a different server.
+ */
+ public String audienceFor(Long serverId, String serverName) {
+ Map aud = token.getAudiences();
+ if (serverName != null && aud.containsKey(serverName)) {
+ return aud.get(serverName);
+ }
+ if (serverId != null && aud.containsKey(String.valueOf(serverId))) {
+ return aud.get(String.valueOf(serverId));
+ }
+ return serverName != null ? serverName : String.valueOf(serverId);
+ }
+
+ /** Signed-token (JWT) settings for the token trust model. */
+ public static class Token {
+ private boolean enabled = false;
+ private String issuer = "mateclaw";
+ private long ttlSeconds = 60;
+ private String keyId = "mateclaw-mcp-1";
+ /** PKCS#8 PEM of the RS256 private key. Required when {@link #enabled}. */
+ private String privateKeyPem = "";
+ private Map audiences = Collections.emptyMap();
+
+ public boolean isEnabled() { return enabled; }
+ public void setEnabled(boolean enabled) { this.enabled = enabled; }
+ public String getIssuer() { return issuer; }
+ public void setIssuer(String issuer) { this.issuer = issuer; }
+ public long getTtlSeconds() { return ttlSeconds; }
+ public void setTtlSeconds(long ttlSeconds) { this.ttlSeconds = ttlSeconds; }
+ public String getKeyId() { return keyId; }
+ public void setKeyId(String keyId) { this.keyId = keyId; }
+ public String getPrivateKeyPem() { return privateKeyPem; }
+ public void setPrivateKeyPem(String privateKeyPem) { this.privateKeyPem = privateKeyPem; }
+ public Map getAudiences() { return audiences; }
+ public void setAudiences(Map audiences) {
+ this.audiences = audiences != null ? audiences : Collections.emptyMap();
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardService.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardService.java
new file mode 100644
index 00000000..636407b6
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardService.java
@@ -0,0 +1,146 @@
+package vip.mate.tool.mcp.runtime;
+
+import io.jsonwebtoken.Jwts;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.stereotype.Service;
+import vip.mate.tool.builtin.ToolExecutionContext;
+
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Date;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Resolves what identity to inject into an opt-in MCP server's tool call, and
+ * (in token mode) mints the signed assertion.
+ *
+ * Two modes, per {@link McpIdentityForwardProperties}:
+ *
+ * - plaintext — returns {@code (__mateclaw_user__, username)}.
+ * - token — returns {@code (__mateclaw_token__, )} with
+ * {@code sub}=username, {@code aud}=server audience, short {@code exp}. The
+ * REST backend verifies the signature with the matching public key, so it
+ * need not trust the MCP service or the transport.
+ *
+ *
+ * The {@code sub} carries the MateClaw user identifier
+ * ({@code ChatOrigin.requesterId}). If your backend keys authorization on an
+ * immutable numeric id, resolve username→id before minting (left as a refinement
+ * so this layer stays decoupled from the user store).
+ *
+ *
Fail-closed: when token mode is enabled but the signing key is missing or
+ * unparseable, nothing is injected (the call goes out without identity and the
+ * backend rejects it) rather than silently downgrading to plaintext.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Service
+public class McpIdentityForwardService {
+
+ private final McpIdentityForwardProperties properties;
+
+ /** Lazily parsed signing key; {@code null} until first use / when unavailable. */
+ private volatile PrivateKey signingKey;
+ private volatile boolean keyParseAttempted;
+
+ public McpIdentityForwardService(McpIdentityForwardProperties properties) {
+ this.properties = properties;
+ }
+
+ public boolean forwardsTo(Long serverId, String serverName) {
+ return properties.forwardsTo(serverId, serverName);
+ }
+
+ public String audienceFor(Long serverId, String serverName) {
+ return properties.audienceFor(serverId, serverName);
+ }
+
+ /** The (key, value) to merge into the call arguments, or empty to inject nothing. */
+ public record Injection(String key, String value) {}
+
+ /**
+ * Resolve the identity injection for a call. Empty when there is no
+ * authenticated user (never fabricate identity), or when token mode is
+ * enabled but the key is unavailable (fail-closed).
+ */
+ public Optional resolve(ToolContext ctx, String audience) {
+ String user = ToolExecutionContext.username(ctx);
+ if (user == null || user.isBlank()) {
+ return Optional.empty();
+ }
+ if (!properties.getToken().isEnabled()) {
+ return Optional.of(new Injection(McpIdentityForwardProperties.USER_ARG, user));
+ }
+ String jwt = mint(user, audience);
+ if (jwt == null) {
+ return Optional.empty(); // fail-closed: token mode but no key
+ }
+ return Optional.of(new Injection(McpIdentityForwardProperties.TOKEN_ARG, jwt));
+ }
+
+ /** Mint a short-lived RS256 JWT, or {@code null} if the key is unavailable. */
+ private String mint(String subject, String audience) {
+ PrivateKey key = signingKey();
+ if (key == null) {
+ return null;
+ }
+ McpIdentityForwardProperties.Token t = properties.getToken();
+ Instant now = Instant.now();
+ try {
+ return Jwts.builder()
+ .header().keyId(t.getKeyId()).and()
+ .issuer(t.getIssuer())
+ .subject(subject)
+ .audience().add(audience).and()
+ .id(UUID.randomUUID().toString())
+ .issuedAt(Date.from(now))
+ .expiration(Date.from(now.plus(Duration.ofSeconds(Math.max(1, t.getTtlSeconds())))))
+ .signWith(key, Jwts.SIG.RS256)
+ .compact();
+ } catch (Exception e) {
+ log.error("[McpIdentity] failed to mint identity token: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ private PrivateKey signingKey() {
+ PrivateKey k = signingKey;
+ if (k != null) {
+ return k;
+ }
+ if (keyParseAttempted) {
+ return null; // already tried and failed; don't spam parsing
+ }
+ synchronized (this) {
+ if (signingKey != null) {
+ return signingKey;
+ }
+ keyParseAttempted = true;
+ String pem = properties.getToken().getPrivateKeyPem();
+ if (pem == null || pem.isBlank()) {
+ log.error("[McpIdentity] token mode enabled but mateclaw.mcp.identity-forward.token.private-key-pem is empty; "
+ + "identity tokens will NOT be issued (fail-closed)");
+ return null;
+ }
+ try {
+ String body = pem.replaceAll("-----BEGIN (.*)-----", "")
+ .replaceAll("-----END (.*)-----", "")
+ .replaceAll("\\s", "");
+ byte[] der = Base64.getDecoder().decode(body);
+ signingKey = KeyFactory.getInstance("RSA")
+ .generatePrivate(new PKCS8EncodedKeySpec(der));
+ log.info("[McpIdentity] loaded RS256 signing key (kid={})", properties.getToken().getKeyId());
+ } catch (Exception e) {
+ log.error("[McpIdentity] failed to parse private-key-pem (expect PKCS#8 RSA): {}", e.getMessage());
+ }
+ return signingKey;
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/resources/docs/en/mcp.md b/mateclaw-server/src/main/resources/docs/en/mcp.md
index b78a1713..f4c41288 100644
--- a/mateclaw-server/src/main/resources/docs/en/mcp.md
+++ b/mateclaw-server/src/main/resources/docs/en/mcp.md
@@ -383,6 +383,148 @@ Keeps secrets out of the database.
---
+## Forwarding the user's identity to an MCP server (on-behalf-of)
+
+A STDIO MCP server is **one shared subprocess per configuration**, used by every
+user; its environment is fixed at spawn and STDIO has no per-request header
+channel like HTTP. So per-user identity **cannot travel via env** — it must ride
+in-band with each tool call.
+
+MateClaw can inject the **authenticated username** into every tool call for a
+chosen server, so the server can call its downstream REST backend on behalf of
+that user.
+
+### Enable (opt-in, per server)
+
+Off by default — injecting into every server would leak the username to any
+third-party MCP server. Enable per server by **name or id**:
+
+```yaml
+mateclaw:
+ mcp:
+ identity-forward:
+ servers:
+ - my-internal-api # server name in mate_mcp_server
+ - 1000000042 # or the numeric server id
+```
+
+### Data contract
+
+When enabled, MateClaw injects the reserved argument **`__mateclaw_user__`**
+(value = authenticated username) into each tool call's JSON arguments. It is
+injected by trusted server code, **never by the LLM** — any model-supplied value
+of the same key is overwritten, so the model cannot spoof identity. When there is
+no authenticated user, nothing is injected (identity is never fabricated).
+
+The MCP server reads and strips the key, then calls REST with it plus its own
+backend API key (e.g. an `X-On-Behalf-Of` header):
+
+```python
+# FastMCP example: MCP server as a Python CLI script (STDIO)
+import os, httpx
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("my-internal-api")
+REST_BASE = os.environ["REST_BASE"]
+API_KEY = os.environ["BACKEND_API_KEY"] # service-level key (authenticates the MCP service)
+
+@mcp.tool()
+def query_orders(keyword: str, __mateclaw_user__: str | None = None) -> str:
+ if not __mateclaw_user__:
+ raise ValueError("missing injected identity") # reject identity-less calls
+ headers = {
+ "Authorization": f"ApiKey {API_KEY}", # service identity
+ "X-On-Behalf-Of": __mateclaw_user__, # the acting user
+ }
+ r = httpx.get(f"{REST_BASE}/orders", params={"q": keyword}, headers=headers, timeout=30)
+ r.raise_for_status()
+ return r.text
+
+if __name__ == "__main__":
+ mcp.run() # STDIO
+```
+
+> If a tool's input schema is `additionalProperties: false`, declare
+> `__mateclaw_user__` as an optional parameter (as above) or strict validation
+> will reject it.
+
+### Two trust models
+
+**① Plaintext (default)**: injects the plaintext username. Fits a trusted
+network where the backend authenticates the MCP service by API key and treats
+the forwarded user as on-behalf-of. The backend trusts the raw string.
+
+**② Signed token (recommended across a trust boundary)**: injects a short-lived
+**RS256 JWT** that MateClaw signs with a private key (reserved key becomes
+**`__mateclaw_token__`**); the REST backend **verifies it with the public key**,
+so it trusts the signature — not the MCP service, the Python script, or the
+transport.
+
+```yaml
+mateclaw:
+ mcp:
+ identity-forward:
+ servers:
+ - my-internal-api
+ token:
+ enabled: true
+ issuer: mateclaw
+ ttl-seconds: 60 # short, tens of seconds
+ key-id: mateclaw-mcp-1
+ private-key-pem: ${MCP_IDFWD_PRIVATE_KEY_PEM:} # PKCS#8 PEM (RS256 private key)
+ audiences: # optional; default aud = server name
+ my-internal-api: https://api.internal
+```
+
+Generate the key pair (private → MateClaw, public → REST backend):
+
+```bash
+openssl genpkey -algorithm RSA -pkcs8 -out mcp-idfwd-private.pem
+openssl pkey -in mcp-idfwd-private.pem -pubout -out mcp-idfwd-public.pem
+# private-key-pem takes the private key body (PEM headers optional; stripped on parse)
+```
+
+Token claims: `iss`, `sub`=user, `aud`=this server, `iat`, `exp` (short), `jti`.
+`aud` + short `exp` bound replay to tens of seconds and to one backend. **When
+token mode is on but no key is configured, it fails closed** (no token minted,
+nothing injected — the backend rejects) rather than silently downgrading to
+plaintext.
+
+> `sub` carries the MateClaw user identifier (`ChatOrigin.requesterId`). If your
+> backend authorizes on an immutable numeric id, resolve username→id before
+> minting (kept decoupled from the user store here).
+
+The MCP server (Python) only forwards — it does not verify:
+
+```python
+@mcp.tool()
+def query_orders(keyword: str, __mateclaw_token__: str | None = None) -> str:
+ if not __mateclaw_token__:
+ raise ValueError("missing identity token")
+ headers = {"Authorization": f"Bearer {__mateclaw_token__}"} # forward to REST
+ return httpx.get(f"{REST_BASE}/orders", params={"q": keyword}, headers=headers, timeout=30).text
+```
+
+REST backend verifies (pseudocode):
+
+```python
+import jwt # PyJWT
+claims = jwt.decode(token, public_key_pem, algorithms=["RS256"],
+ issuer="mateclaw", audience="https://api.internal")
+user = claims["sub"] # trusted only after signature verification
+# → per-user authorization; invalid/expired → 401
+```
+
+> Public-key distribution: for now an operator configures the public key on the
+> REST side out-of-band. A JWKS endpoint for auto-distribution + rotation is a
+> natural follow-up.
+>
+> Relationship to the API key: you can keep the API key as service/channel auth
+> ("this MCP service may talk to the backend") plus the JWT as the user
+> assertion — two clean layers — or let the JWT carry both.
+
+---
+
## Troubleshooting
### "Command not found" (stdio)
diff --git a/mateclaw-server/src/main/resources/docs/zh/mcp.md b/mateclaw-server/src/main/resources/docs/zh/mcp.md
index c87fed32..b0500f83 100644
--- a/mateclaw-server/src/main/resources/docs/zh/mcp.md
+++ b/mateclaw-server/src/main/resources/docs/zh/mcp.md
@@ -379,6 +379,119 @@ API 响应里 `headers_json` 和 `env_json` 的值自动**脱敏**。`args_json`
---
+## 透传用户身份给 MCP server(on-behalf-of)
+
+STDIO MCP server 是**每个配置一个共享子进程**,所有用户共用;env 在子进程启动时一次性注入、之后不可变,STDIO 也没有 HTTP 那种 per-request header 通道。所以**不能用 env 传 per-user 身份**——身份必须随每次工具调用在带内传递。
+
+MateClaw 支持把**认证用户名**注入到每次工具调用的参数里,让 MCP server 代表该用户调用底层 REST 后端。
+
+### 开启(opt-in,按 server)
+
+默认关闭——全量注入会把用户名泄漏给任意第三方 MCP server。用允许清单按 **server 名或 id** 开启:
+
+```yaml
+mateclaw:
+ mcp:
+ identity-forward:
+ servers:
+ - my-internal-api # mate_mcp_server 里的 server 名
+ - 1000000042 # 或数字 server id
+```
+
+### 数据契约
+
+开启后,MateClaw 在调用该 server 的每个工具时,往参数 JSON 里注入保留字段 **`__mateclaw_user__`**(值=认证用户名)。该值由受信服务端注入、**不经 LLM**;若 LLM 伪造了同名字段会被覆盖,因此模型无法冒充身份。无认证用户时不注入(不伪造身份)。
+
+MCP server 侧读出该字段、剥掉,再连同自己持有的后端 API Key 一起调 REST(如 `X-On-Behalf-Of` header):
+
+```python
+# FastMCP 示例:MCP server 用 Python 命令行脚本(STDIO)
+import os, httpx
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("my-internal-api")
+REST_BASE = os.environ["REST_BASE"] # 后端地址
+API_KEY = os.environ["BACKEND_API_KEY"] # 服务级 API Key(认证 MCP 服务本身)
+
+@mcp.tool()
+def query_orders(keyword: str, __mateclaw_user__: str | None = None) -> str:
+ if not __mateclaw_user__:
+ raise ValueError("missing injected identity") # 拒绝无身份调用
+ headers = {
+ "Authorization": f"ApiKey {API_KEY}", # 服务身份
+ "X-On-Behalf-Of": __mateclaw_user__, # 代表的用户
+ }
+ r = httpx.get(f"{REST_BASE}/orders", params={"q": keyword}, headers=headers, timeout=30)
+ r.raise_for_status()
+ return r.text
+
+if __name__ == "__main__":
+ mcp.run() # STDIO
+```
+
+> 工具的入参 schema 若是 `additionalProperties: false`,记得像上面那样把 `__mateclaw_user__` 声明为可选参数,否则严格校验会拒绝。
+
+### 两种信任模型
+
+**① 明文(默认)**:注入明文用户名。适合 REST 在内网、且后端用 API Key 认证 MCP 服务、把转发用户当 on-behalf-of 的场景。后端裸信这个字符串。
+
+**② 签名 token(推荐用于跨信任边界)**:注入一个 MateClaw 用私钥现签的**短时 RS256 JWT**(保留字段换成 **`__mateclaw_token__`**),REST 后端用**公钥验签**——它信任的是签名,而非 MCP 服务/Python/传输。
+
+```yaml
+mateclaw:
+ mcp:
+ identity-forward:
+ servers:
+ - my-internal-api
+ token:
+ enabled: true
+ issuer: mateclaw
+ ttl-seconds: 60 # 短时,几十秒
+ key-id: mateclaw-mcp-1
+ private-key-pem: ${MCP_IDFWD_PRIVATE_KEY_PEM:} # PKCS#8 PEM(RS256 私钥)
+ audiences: # 可选;默认 aud = server 名
+ my-internal-api: https://api.internal
+```
+
+生成密钥对(私钥配给 MateClaw,公钥配给 REST 后端):
+
+```bash
+openssl genpkey -algorithm RSA -pkcs8 -out mcp-idfwd-private.pem
+openssl pkey -in mcp-idfwd-private.pem -pubout -out mcp-idfwd-public.pem
+# private-key-pem 用私钥内容(带不带 PEM 头都行,解析时会剥掉)
+```
+
+token 的 claims:`iss`、`sub`=用户、`aud`=该 server、`iat`、`exp`(短)、`jti`。`aud`+短 `exp` 把重放限制在几十秒内、且只对这一个后端。**token 模式开启但没配私钥时 fail-closed**(不签、不注入,后端自然拒绝),不会偷偷退回明文。
+
+> `sub` 携带的是 MateClaw 用户标识(`ChatOrigin.requesterId`)。若后端按不可变数字 id 鉴权,可在签发前把用户名解析成 id(本层刻意不耦合用户存储)。
+
+MCP server(Python)只透传、不验签:
+
+```python
+@mcp.tool()
+def query_orders(keyword: str, __mateclaw_token__: str | None = None) -> str:
+ if not __mateclaw_token__:
+ raise ValueError("missing identity token")
+ headers = {"Authorization": f"Bearer {__mateclaw_token__}"} # 直接透传给 REST
+ return httpx.get(f"{REST_BASE}/orders", params={"q": keyword}, headers=headers, timeout=30).text
+```
+
+REST 后端验签(伪代码):
+
+```python
+import jwt # PyJWT
+claims = jwt.decode(token, public_key_pem, algorithms=["RS256"],
+ issuer="mateclaw", audience="https://api.internal")
+user = claims["sub"] # 验签通过才相信
+# → 按 user 做 per-user 授权;验签失败/过期 → 401
+```
+
+> 公钥分发:当前由运维把上面生成的公钥配到 REST 侧(带外)。后续可加一个 JWKS 端点自动分发+轮换。
+>
+> 与 API Key 的关系:可保留 API Key 作"服务/通道认证"(这台 MCP 服务被允许跟后端说话)+ JWT 作"用户断言",双层更清晰;也可让 JWT 一肩挑。
+
+---
+
## 故障排查
### "命令找不到"(stdio)
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallbackTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallbackTest.java
new file mode 100644
index 00000000..eba5d4a8
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallbackTest.java
@@ -0,0 +1,70 @@
+package vip.mate.tool.mcp.runtime;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link IdentityForwardingToolCallback#withClaim} — the in-band
+ * JSON merge that carries identity to a STDIO MCP server. Pure string/JSON
+ * behavior; identity resolution (plaintext vs token) is tested in
+ * {@link McpIdentityForwardServiceTest}.
+ */
+class IdentityForwardingToolCallbackTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final String KEY = McpIdentityForwardProperties.USER_ARG;
+
+ @Test
+ @DisplayName("merges the claim into JSON args under the given key")
+ void mergesClaim() throws Exception {
+ JsonNode n = MAPPER.readTree(IdentityForwardingToolCallback.withClaim("{\"q\":\"hi\"}", KEY, "alice"));
+ assertThat(n.get("q").asText()).isEqualTo("hi");
+ assertThat(n.get(KEY).asText()).isEqualTo("alice");
+ }
+
+ @Test
+ @DisplayName("overwrites an LLM-supplied value of the reserved key (no spoofing)")
+ void overwritesLlmSuppliedValue() throws Exception {
+ String out = IdentityForwardingToolCallback.withClaim(
+ "{\"q\":\"hi\",\"" + KEY + "\":\"attacker\"}", KEY, "alice");
+ assertThat(MAPPER.readTree(out).get(KEY).asText()).isEqualTo("alice");
+ }
+
+ @Test
+ @DisplayName("blank/empty input becomes a fresh object carrying the claim")
+ void emptyInputGetsObject() throws Exception {
+ for (String in : new String[]{null, "", " "}) {
+ String out = IdentityForwardingToolCallback.withClaim(in, KEY, "bob");
+ assertThat(MAPPER.readTree(out).get(KEY).asText()).isEqualTo("bob");
+ }
+ }
+
+ @Test
+ @DisplayName("non-object args (array/scalar) are forwarded unchanged, not corrupted")
+ void nonObjectInputUnchanged() {
+ assertThat(IdentityForwardingToolCallback.withClaim("[1,2,3]", KEY, "alice")).isEqualTo("[1,2,3]");
+ assertThat(IdentityForwardingToolCallback.withClaim("\"plain\"", KEY, "alice")).isEqualTo("\"plain\"");
+ }
+
+ @Test
+ @DisplayName("malformed JSON is forwarded unchanged (surfaces downstream, not masked)")
+ void malformedJsonUnchanged() {
+ assertThat(IdentityForwardingToolCallback.withClaim("{not json", KEY, "alice")).isEqualTo("{not json");
+ }
+
+ @Test
+ @DisplayName("opt-in matching: by id or name, empty set never forwards")
+ void optInMatching() {
+ McpIdentityForwardProperties p = new McpIdentityForwardProperties();
+ assertThat(p.forwardsTo(42L, "svc")).isFalse(); // empty set
+ p.setServers(java.util.Set.of("svc"));
+ assertThat(p.forwardsTo(42L, "svc")).isTrue(); // by name
+ assertThat(p.forwardsTo(42L, "other")).isFalse();
+ p.setServers(java.util.Set.of("42"));
+ assertThat(p.forwardsTo(42L, "other")).isTrue(); // by id
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSnapshotTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSnapshotTest.java
index e62a57fd..96c127c2 100644
--- a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSnapshotTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSnapshotTest.java
@@ -37,7 +37,8 @@ class McpClientManagerSnapshotTest {
@SuppressWarnings("unchecked")
void staleListToolsServesSnapshotAndRequestsReconnect() throws Exception {
ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class);
- McpClientManager manager = new McpClientManager(publisher);
+ McpClientManager manager = new McpClientManager(publisher,
+ new McpIdentityForwardService(new McpIdentityForwardProperties()));
// A client whose connection went stale: every listTools() throws.
McpSyncClient deadClient = mock(McpSyncClient.class);
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpIdentityForwardServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpIdentityForwardServiceTest.java
new file mode 100644
index 00000000..ed22a561
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpIdentityForwardServiceTest.java
@@ -0,0 +1,99 @@
+package vip.mate.tool.mcp.runtime;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import vip.mate.tool.builtin.ToolExecutionContext;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.Base64;
+import java.util.Date;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests {@link McpIdentityForwardService}: plaintext vs signed-token resolution,
+ * fail-closed when token mode lacks a key, and that a minted token verifies
+ * against the public key with the right claims.
+ */
+class McpIdentityForwardServiceTest {
+
+ @AfterEach
+ void clear() {
+ ToolExecutionContext.clear();
+ }
+
+ private McpIdentityForwardService svc(McpIdentityForwardProperties p) {
+ return new McpIdentityForwardService(p);
+ }
+
+ @Test
+ @DisplayName("plaintext mode: injects username under USER_ARG")
+ void plaintext() {
+ ToolExecutionContext.set("c1", "alice");
+ var p = new McpIdentityForwardProperties();
+ Optional inj = svc(p).resolve(null, "my-api");
+ assertThat(inj).isPresent();
+ assertThat(inj.get().key()).isEqualTo(McpIdentityForwardProperties.USER_ARG);
+ assertThat(inj.get().value()).isEqualTo("alice");
+ }
+
+ @Test
+ @DisplayName("no authenticated user: nothing injected")
+ void noUser() {
+ var p = new McpIdentityForwardProperties();
+ assertThat(svc(p).resolve(null, "my-api")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("token mode but no key: fail-closed (nothing injected)")
+ void tokenModeNoKey() {
+ ToolExecutionContext.set("c1", "alice");
+ var p = new McpIdentityForwardProperties();
+ p.getToken().setEnabled(true); // no private-key-pem
+ assertThat(svc(p).resolve(null, "my-api")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("token mode: mints an RS256 JWT that verifies with the public key")
+ void tokenMintAndVerify() throws Exception {
+ KeyPair kp = KeyPairGenerator.getInstance("RSA").genKeyPair(); // 2048 default
+ var p = new McpIdentityForwardProperties();
+ p.getToken().setEnabled(true);
+ p.getToken().setIssuer("mateclaw");
+ p.getToken().setTtlSeconds(60);
+ p.getToken().setPrivateKeyPem(Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded()));
+
+ ToolExecutionContext.set("c1", "alice");
+ Optional inj = svc(p).resolve(null, "my-api");
+
+ assertThat(inj).isPresent();
+ assertThat(inj.get().key()).isEqualTo(McpIdentityForwardProperties.TOKEN_ARG);
+
+ // The REST backend would do exactly this: verify with the public key.
+ Claims claims = Jwts.parser()
+ .verifyWith(kp.getPublic())
+ .requireIssuer("mateclaw")
+ .requireAudience("my-api")
+ .build()
+ .parseSignedClaims(inj.get().value())
+ .getPayload();
+
+ assertThat(claims.getSubject()).isEqualTo("alice");
+ assertThat(claims.getExpiration()).isAfter(new Date());
+ assertThat(claims.getId()).isNotBlank(); // jti present
+ }
+
+ @Test
+ @DisplayName("audienceFor: explicit mapping wins, else server name")
+ void audienceResolution() {
+ var p = new McpIdentityForwardProperties();
+ assertThat(p.audienceFor(42L, "svc")).isEqualTo("svc"); // default = name
+ p.getToken().setAudiences(java.util.Map.of("svc", "https://api.internal"));
+ assertThat(p.audienceFor(42L, "svc")).isEqualTo("https://api.internal");
+ }
+}