feat(mcp): STDIO MCP server 透传认证用户身份(opt-in per server) (#460)

* feat(mcp): forward authenticated user identity to opt-in STDIO MCP servers

A STDIO MCP server is one shared subprocess per configuration; its env is fixed
at spawn and STDIO has no per-request header channel, so per-user identity must
travel in-band with each tool call. Previously nothing carried it, so an MCP
server could not call its downstream REST backend on behalf of the acting user.

Inject the authenticated username (from ToolExecutionContext) into each tool
call's JSON arguments under the reserved key `__mateclaw_user__`, for servers an
operator explicitly opts in via `mateclaw.mcp.identity-forward.servers` (by name
or id). The MCP server reads/strips it and forwards on-behalf-of alongside its
own backend API key.

- McpIdentityForwardProperties: per-server opt-in allowlist (name or id).
- IdentityForwardingToolCallback: wraps an MCP callback, merges the username
  into the args; injected by trusted code, overwrites any LLM-supplied value
  (no spoofing); forwards unchanged when there is no user or args aren't an
  object/are malformed.
- McpClientManager: captures server names; wraps opt-in servers' callbacks
  inside the prefix wrapper (so name-prefixing / return-direct still see the raw
  delegate). Non-opt-in servers are untouched — username never leaks to them.
- Tests: injection, LLM-value overwrite, empty/non-object/malformed inputs,
  no-user passthrough, opt-in matching by id/name.
- Docs (zh/en mcp.md): opt-in config, `__mateclaw_user__` contract, FastMCP
  Python skeleton, trust model.

Default off (empty allowlist) — zero behavior change for existing servers.
Plaintext username suits a trusted-network REST backend keyed by an API key;
a signed short-lived token is noted as the stronger-isolation follow-up.

* feat(mcp): add signed-token trust model for MCP identity forwarding

Plaintext username forwarding makes the REST backend trust an unverifiable
assertion from the (shared, LLM-adjacent) MCP service — a confused-deputy model.
Add an opt-in signed-token mode so identity crosses the trust boundary as a
short-lived RS256 JWT the backend can verify with a public key.

- McpIdentityForwardProperties: nested `token` config (enabled, issuer,
  ttl-seconds, key-id, private-key-pem, audiences) + USER_ARG/TOKEN_ARG keys.
- McpIdentityForwardService: resolves the injection — plaintext username
  (__mateclaw_user__) when token mode off, else a minted RS256 JWT
  (__mateclaw_token__) with sub=user, aud=server, short exp, jti. Lazy key
  parse; fail-closed when token mode is on but the key is missing/unparseable
  (no silent downgrade to plaintext). Signs with MateClaw's private key so the
  backend only needs the public key (cannot mint/impersonate).
- IdentityForwardingToolCallback: now delegates the what-to-inject decision to
  the service (keyed by per-server audience); static withClaim() keeps the
  JSON-merge logic (overwrites LLM-supplied key, leaves non-object/malformed
  args untouched).
- McpClientManager: injects the service; passes service + audience through the
  wrap path for opt-in servers only.
- Tests: token mint+verify (with an in-test RSA keypair, asserting sub/aud/iss/
  exp/jti), plaintext mode, no-user and no-key fail-closed, audience resolution.
- Docs (zh/en): token config, key generation, claims, REST-side verification
  example, public-key distribution + JWKS-endpoint follow-up.

Default unchanged: token.enabled=false → plaintext (back-compat); whole feature
still opt-in per server and off by default.
This commit is contained in:
倪程伟 2026-07-01 18:51:08 +08:00 committed by GitHub
parent 3ac73623ee
commit 758bdbb94b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 870 additions and 4 deletions

View File

@ -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.
*
* <p>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__}).
*
* <p>STDIO has no per-request header channel and the subprocess is shared by all
* users, so identity must ride <em>in-band, per call</em>. 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.
*
* <p>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 <em>inside</em> {@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;
}
}
}

View File

@ -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<Long, String> 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<ToolCallback> 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<ToolCallback> 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<ToolCallback> 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<ToolCallback> wrapServerCallbacks(long serverId, ToolCallback[] cbs,
McpIdentityForwardService identitySvc, String audience) {
List<String> 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);

View File

@ -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.
*
* <p>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:
*
* <ul>
* <li><b>Plaintext</b> (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.</li>
* <li><b>Signed token</b> ({@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 <em>verifies</em> 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.</li>
* </ul>
*
* <p><b>Why opt-in, and per server.</b> A single STDIO subprocess is shared by
* every user (the client pool is keyed by server id), so identity can only
* travel <em>in-band per call</em> never via the process environment, which is
* fixed at spawn. And forwarding identity to <em>every</em> MCP server would leak
* it to any third-party server an operator adds. So it is off by default and
* enabled per trusted server.
*
* <p>Configuration ({@code application.yml}):
* <pre>
* mateclaw:
* mcp:
* identity-forward:
* servers:
* - my-internal-api # MCP server name (mate_mcp_server) or numeric id
* token:
* enabled: true # off =&gt; 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 -&gt; aud; default aud = server name
* my-internal-api: my-internal-api
* </pre>
*
* @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<String> servers = Collections.emptySet();
private Token token = new Token();
public Set<String> getServers() {
return servers;
}
public void setServers(Set<String> 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<String, String> 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<String, String> 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<String, String> getAudiences() { return audiences; }
public void setAudiences(Map<String, String> audiences) {
this.audiences = audiences != null ? audiences : Collections.emptyMap();
}
}
}

View File

@ -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.
*
* <p>Two modes, per {@link McpIdentityForwardProperties}:
* <ul>
* <li><b>plaintext</b> returns {@code (__mateclaw_user__, username)}.</li>
* <li><b>token</b> returns {@code (__mateclaw_token__, <RS256 JWT>)} 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.</li>
* </ul>
*
* <p>The {@code sub} carries the MateClaw user identifier
* ({@code ChatOrigin.requesterId}). If your backend keys authorization on an
* immutable numeric id, resolve usernameid before minting (left as a refinement
* so this layer stays decoupled from the user store).
*
* <p>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<Injection> 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;
}
}
}

View File

@ -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)

View File

@ -379,6 +379,119 @@ API 响应里 `headers_json` 和 `env_json` 的值自动**脱敏**。`args_json`
---
## 透传用户身份给 MCP serveron-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 PEMRS256 私钥)
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 serverPython只透传、不验签
```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

View File

@ -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
}
}

View File

@ -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);

View File

@ -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<McpIdentityForwardService.Injection> 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<McpIdentityForwardService.Injection> 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");
}
}