Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45e12324df | |||
| 09b588326c |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "a2amcp",
|
"name": "a2amcp",
|
||||||
"version": "0.1.4",
|
"version": "0.1.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "a2amcp",
|
"name": "a2amcp",
|
||||||
"version": "0.1.4",
|
"version": "0.1.6",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "a2amcp",
|
"name": "a2amcp",
|
||||||
"version": "0.1.4",
|
"version": "0.1.6",
|
||||||
"description": "MCP gateway for a2acloud agents. Run as a local MCP server; expose any number of deployed A2A agents to Claude Code, Cursor, and other MCP clients.",
|
"description": "MCP gateway for a2acloud agents. Run as a local MCP server; expose any number of deployed A2A agents to Claude Code, Cursor, and other MCP clients.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
|
|||||||
return { tools };
|
return { tools };
|
||||||
});
|
});
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
||||||
const fullName = req.params.name;
|
const fullName = req.params.name;
|
||||||
const idx = fullName.indexOf(SEP);
|
const idx = fullName.indexOf(SEP);
|
||||||
if (idx < 0) {
|
if (idx < 0) {
|
||||||
@@ -107,10 +107,30 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
|
|||||||
if (!upstream) {
|
if (!upstream) {
|
||||||
return _softError(`unknown agent: ${agentName}`) as any;
|
return _softError(`unknown agent: ${agentName}`) as any;
|
||||||
}
|
}
|
||||||
|
// Honor the client's progressToken if it supplied one; else fall
|
||||||
|
// back to the request id. Either way, when upstream emits
|
||||||
|
// notifications/progress we forward them via the SDK's
|
||||||
|
// sendNotification so clients like Claude Code reset their idle
|
||||||
|
// tool-call timer and don't abort long builds.
|
||||||
|
const clientToken = (req.params._meta as any)?.progressToken as
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| undefined;
|
||||||
|
const progressToken = clientToken ?? (extra.requestId as string | number);
|
||||||
try {
|
try {
|
||||||
const result = await upstream.callTool(
|
const result = await upstream.callTool(
|
||||||
toolName,
|
toolName,
|
||||||
(req.params.arguments as Record<string, unknown>) ?? {},
|
(req.params.arguments as Record<string, unknown>) ?? {},
|
||||||
|
{
|
||||||
|
progressToken,
|
||||||
|
onNotification: async (notif) => {
|
||||||
|
try {
|
||||||
|
await extra.sendNotification(notif as any);
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
// Pass through the upstream's CallToolResult as-is. Upstreams already
|
// Pass through the upstream's CallToolResult as-is. Upstreams already
|
||||||
// populate content / structuredContent / isError per the MCP spec.
|
// populate content / structuredContent / isError per the MCP spec.
|
||||||
|
|||||||
119
src/upstream.ts
119
src/upstream.ts
@@ -99,13 +99,130 @@ export class UpstreamAgent {
|
|||||||
async callTool(
|
async callTool(
|
||||||
name: string,
|
name: string,
|
||||||
arguments_: Record<string, unknown>,
|
arguments_: Record<string, unknown>,
|
||||||
|
opts: {
|
||||||
|
onNotification?: (notif: {
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}) => void | Promise<void>;
|
||||||
|
progressToken?: string | number;
|
||||||
|
} = {},
|
||||||
): Promise<UpstreamCallResult> {
|
): Promise<UpstreamCallResult> {
|
||||||
const meta: Record<string, unknown> = {};
|
const meta: Record<string, unknown> = {};
|
||||||
if (this.cfg.cpJwt) meta.cp_jwt = this.cfg.cpJwt;
|
if (this.cfg.cpJwt) meta.cp_jwt = this.cfg.cpJwt;
|
||||||
if (this.cfg.cpUrl) meta.cp_url = this.cfg.cpUrl;
|
if (this.cfg.cpUrl) meta.cp_url = this.cfg.cpUrl;
|
||||||
if (this.cfg.bucket) meta.bucket = this.cfg.bucket;
|
if (this.cfg.bucket) meta.bucket = this.cfg.bucket;
|
||||||
|
if (opts.progressToken !== undefined) meta.progressToken = opts.progressToken;
|
||||||
const params: Record<string, unknown> = { name, arguments: arguments_ };
|
const params: Record<string, unknown> = { name, arguments: arguments_ };
|
||||||
if (Object.keys(meta).length > 0) params._meta = meta;
|
if (Object.keys(meta).length > 0) params._meta = meta;
|
||||||
return this.rpc<UpstreamCallResult>("tools/call", params);
|
return this.streamingCallTool(params, opts.onNotification);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tools/call always negotiates SSE on the wire — upstream agents can
|
||||||
|
* take minutes (agent-builder.build runs an LLM loop + sandbox tests +
|
||||||
|
* a deploy + a wait-for-live-card poll), and a plain JSON POST gets
|
||||||
|
* killed by ingress idle timeouts (~60s default on traefik). The
|
||||||
|
* upstream's SSE response interleaves any elicitation/create requests
|
||||||
|
* and a final tools/call result; here we drain the stream and return
|
||||||
|
* the final result. (Elicit relaying to the MCP client is not wired
|
||||||
|
* yet — any elicit that fires will time out on the upstream side and
|
||||||
|
* the skill will surface a soft error.)
|
||||||
|
*/
|
||||||
|
private async streamingCallTool(
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
onNotification?: (notif: {
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}) => void | Promise<void>,
|
||||||
|
): Promise<UpstreamCallResult> {
|
||||||
|
const id = _nextId++;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
accept: "text/event-stream, application/json",
|
||||||
|
};
|
||||||
|
if (this.cfg.token) headers["authorization"] = `Bearer ${this.cfg.token}`;
|
||||||
|
|
||||||
|
const resp = await fetch(`${this.cfg.url}/mcp`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: JSON_RPC, id, method: "tools/call", params,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text().catch(() => "");
|
||||||
|
throw new UpstreamError(this.cfg.name, resp.status, text || resp.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctype = resp.headers.get("content-type") || "";
|
||||||
|
if (!ctype.includes("text/event-stream")) {
|
||||||
|
// Server fell back to JSON (older a2a-pack or non-SSE upstream).
|
||||||
|
const env: any = await resp.json();
|
||||||
|
if (env?.error) {
|
||||||
|
throw new UpstreamError(
|
||||||
|
this.cfg.name, 500,
|
||||||
|
`${env.error.code}: ${env.error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return env?.result as UpstreamCallResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.body) {
|
||||||
|
throw new UpstreamError(this.cfg.name, 502, "empty stream body");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (value) buf += decoder.decode(value, { stream: true });
|
||||||
|
let sep = buf.indexOf("\n\n");
|
||||||
|
while (sep !== -1) {
|
||||||
|
const frame = buf.slice(0, sep);
|
||||||
|
buf = buf.slice(sep + 2);
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
for (const ln of frame.split("\n")) {
|
||||||
|
if (ln.startsWith("data:")) dataLines.push(ln.slice(5).trimStart());
|
||||||
|
}
|
||||||
|
if (dataLines.length > 0) {
|
||||||
|
const raw = dataLines.join("\n");
|
||||||
|
let msg: any;
|
||||||
|
try { msg = JSON.parse(raw); } catch { msg = null; }
|
||||||
|
if (msg && msg.id === id) {
|
||||||
|
if (msg.error) {
|
||||||
|
throw new UpstreamError(
|
||||||
|
this.cfg.name, 500,
|
||||||
|
`${msg.error.code}: ${msg.error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return msg.result as UpstreamCallResult;
|
||||||
|
}
|
||||||
|
// Otherwise it's a server→client message. Notifications
|
||||||
|
// (no id) → forward to the MCP client so long-running
|
||||||
|
// skills keep the client's tool-call timer alive. Requests
|
||||||
|
// (has id, has method) — e.g. elicitation/create — are
|
||||||
|
// not relayed yet, so they will time out upstream.
|
||||||
|
if (msg && typeof msg.method === "string" && !("id" in msg) && onNotification) {
|
||||||
|
try {
|
||||||
|
await onNotification({
|
||||||
|
method: msg.method,
|
||||||
|
params: msg.params as Record<string, unknown> | undefined,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort; never break the stream on a notify error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sep = buf.indexOf("\n\n");
|
||||||
|
}
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { reader.releaseLock(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
throw new UpstreamError(this.cfg.name, 502, "stream ended without result");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user