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: + * + *

+ * + *

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"); + } +}