From 3c0294a1b90f3552e5c103a6466fce74515bd536 Mon Sep 17 00:00:00 2001 From: Nivesh353 Date: Wed, 17 Jun 2026 19:52:20 +0530 Subject: [PATCH 1/4] feat(mcp): add MCP client support Connect to MCP servers declared in agent.yaml / SDK mcpServers, register their tools as native AgentTools (namespaced __), and tear connections down on exit. stdio + HTTP + SSE transports, pagination, name sanitization, abort forwarding, fail-soft connect, recursive JSON Schema conversion. Fully opt-in; SDK not loaded when unused. --- README.md | 64 +++ package-lock.json | 1091 +++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + src/env-utils.ts | 31 ++ src/exports.ts | 1 + src/index.ts | 9 +- src/loader.ts | 2 + src/mcp/manager.ts | 254 +++++++++++ src/mcp/types.ts | 31 ++ src/plugins.ts | 3 +- src/sdk-types.ts | 3 + src/sdk.ts | 14 + src/tool-loader.ts | 76 ++- test/mcp.test.ts | 167 +++++++ 14 files changed, 1721 insertions(+), 26 deletions(-) create mode 100644 src/env-utils.ts create mode 100644 src/mcp/manager.ts create mode 100644 src/mcp/types.ts create mode 100644 test/mcp.test.ts diff --git a/README.md b/README.md index c684c6d..209c507 100644 --- a/README.md +++ b/README.md @@ -579,6 +579,70 @@ my-plugin/ └── index.ts # Programmatic entry point ``` +## MCP (Model Context Protocol) + +Gitagent is an **MCP client**: point it at any [MCP server](https://modelcontextprotocol.io) and that server's tools are automatically discovered and made available to the agent — no integration code to write. This unlocks the whole ecosystem of ready-made servers (filesystem, GitHub, Postgres, Slack, fetch, …). + +### Configure servers in `agent.yaml` + +```yaml +mcp_servers: + filesystem: # local server over stdio (default) + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/data"] + env: + LOG_LEVEL: "${MCP_LOG_LEVEL}" # ${VAR} interpolated from the environment + timeoutMs: 30000 # connect/list timeout (default 30000) + + analytics: # remote server over Streamable HTTP + type: http + url: "https://mcp.example.com/mcp" + headers: + Authorization: "Bearer ${ANALYTICS_TOKEN}" + + legacy: # legacy SSE transport (deprecated) + type: sse + url: "https://old.example.com/sse" +``` + +On startup gitagent connects to each server, lists its tools, and registers them as **`__`** (e.g. `filesystem__read_file`, `analytics__query`). Connections are torn down automatically when the session ends. + +| Field | Applies to | Description | +|---|---|---| +| `command` / `args` / `env` / `cwd` | stdio | How to launch a local server | +| `type: http \| sse` + `url` + `headers` | remote | Connect to a remote server | +| `timeoutMs` | both | Connect + list-tools timeout (default `30000`) | + +### Use via the SDK + +```typescript +import { query } from "gitagent"; + +for await (const msg of query({ + prompt: "Summarize last week's signups from the database", + mcpServers: { + postgres: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-postgres", process.env.DB_URL!], + }, + }, +})) { + if (msg.type === "tool_use") console.log(`calling ${msg.toolName}`); +} +``` + +SDK `mcpServers` are merged with any `agent.yaml` `mcp_servers` (the SDK value wins on a key collision). + +### Behavior & guarantees + +- **Fail-soft:** a server that can't start (or times out) is logged and skipped — other servers and built-in tools keep working. +- **Namespaced & sanitized:** tool names are prefixed with the server name and cleaned to satisfy provider naming rules. +- **Pagination:** servers that paginate their tool list are fully enumerated. +- **Cleanup:** stdio servers (child processes) are shut down on every exit path (normal, `/quit`, Ctrl+C, error). +- **Lazy:** if no servers are configured, the MCP SDK is never loaded. + +> Note: v1 supports MCP **tools**. Resources and prompts are not yet exposed. + ## Multi-Model Support Gitagent works with any LLM provider supported by [pi-ai](https://github.com/badlogic/pi-mono/tree/main/packages/ai): diff --git a/package-lock.json b/package-lock.json index 1e81523..ecec55a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@googleworkspace/cli": "^0.8.1", "@mariozechner/pi-agent-core": "^0.70.2", "@mariozechner/pi-ai": "^0.70.2", + "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.215.0", "@opentelemetry/exporter-trace-otlp-http": "^0.215.0", @@ -1167,6 +1168,18 @@ "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", "license": "BSD-3-Clause" }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1740,6 +1753,46 @@ "zod-to-json-schema": "^3.25.0" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -2954,6 +3007,19 @@ "@types/node": "*" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2984,6 +3050,39 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3122,6 +3221,43 @@ "node": "*" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -3134,6 +3270,15 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cacheable": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz", @@ -3147,6 +3292,35 @@ "qified": "^0.10.1" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -3197,6 +3371,19 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -3206,6 +3393,55 @@ "node": ">= 0.6" } }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/curve25519-js": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", @@ -3252,15 +3488,39 @@ "node": ">= 14" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3270,12 +3530,57 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3285,6 +3590,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -3337,18 +3648,131 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -3427,6 +3851,27 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -3439,8 +3884,35 @@ "node": ">=12.20.0" } }, - "node_modules/gaxios": { - "version": "7.1.4", + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", "license": "Apache-2.0", @@ -3476,6 +3948,43 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-uri": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", @@ -3525,6 +4034,30 @@ "node": ">=14" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -3537,12 +4070,53 @@ "node": ">=20" } }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3569,6 +4143,22 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -3604,6 +4194,12 @@ "node": ">=18" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -3613,6 +4209,15 @@ "node": ">= 12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3622,6 +4227,27 @@ "node": ">=8" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -3656,6 +4282,18 @@ "node": ">=16" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -3717,6 +4355,15 @@ "node": "20 || >=22" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -3726,6 +4373,43 @@ "node": ">= 0.8" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -3769,6 +4453,15 @@ "node": ">=18" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/netmask": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", @@ -3828,6 +4521,27 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -3837,6 +4551,27 @@ "node": ">=14.0.0" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/openai": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", @@ -3931,6 +4666,15 @@ "node": ">= 14" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/partial-json": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", @@ -3952,6 +4696,25 @@ "node": ">=14.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", @@ -3989,6 +4752,15 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -4029,6 +4801,19 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", @@ -4081,12 +4866,51 @@ "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", "license": "MIT" }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -4105,6 +4929,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-in-the-middle": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", @@ -4127,6 +4960,22 @@ "node": ">= 4" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4156,6 +5005,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", @@ -4169,6 +5024,57 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -4214,6 +5120,99 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -4280,6 +5279,15 @@ "node": ">= 10.x" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4343,6 +5351,15 @@ "real-require": "^0.2.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/token-types": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", @@ -4373,6 +5390,37 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typebox": { "version": "1.1.38", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", @@ -4420,6 +5468,15 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -4430,6 +5487,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -4445,6 +5511,21 @@ "integrity": "sha512-yYO1qSs0Fe7tGtnxOFHomocUD6IZtoAgmA4oDFyGIRZ67D3QZk3w7swA6XXFXNQngiyrg2k7tul6IrM3eUFh7A==", "license": "MIT" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/win-guid": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", @@ -4468,6 +5549,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/package.json b/package.json index 1ca9522..fe325be 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@googleworkspace/cli": "^0.8.1", "@mariozechner/pi-agent-core": "^0.70.2", "@mariozechner/pi-ai": "^0.70.2", + "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.215.0", "@opentelemetry/exporter-trace-otlp-http": "^0.215.0", diff --git a/src/env-utils.ts b/src/env-utils.ts new file mode 100644 index 0000000..ca08d3b --- /dev/null +++ b/src/env-utils.ts @@ -0,0 +1,31 @@ +/** + * Shared `${VAR}` environment-variable interpolation. + * + * Replaces `${VAR_NAME}` occurrences in strings with `process.env.VAR_NAME`, + * or an empty string when the variable is unset (matching the long-standing + * plugin-config behavior). Recurses through arrays and plain objects so an + * entire config object (e.g. an MCP server definition) can be interpolated in + * one call. Non-string leaf values are returned unchanged. + */ +const ENV_VAR_PATTERN = /\$\{(\w+)\}/g; + +export function interpolateEnvString(value: string): string { + return value.replace(ENV_VAR_PATTERN, (_, envName) => process.env[envName] || ""); +} + +export function interpolateEnv(value: T): T { + if (typeof value === "string") { + return interpolateEnvString(value) as unknown as T; + } + if (Array.isArray(value)) { + return value.map((item) => interpolateEnv(item)) as unknown as T; + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, v] of Object.entries(value as Record)) { + out[key] = interpolateEnv(v); + } + return out as unknown as T; + } + return value; +} diff --git a/src/exports.ts b/src/exports.ts index 2748236..5c9dd03 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -23,6 +23,7 @@ export type { // Internal types (for advanced usage) export type { AgentManifest, LoadedAgent } from "./loader.js"; +export type { McpServerConfig } from "./mcp/types.js"; export type { SkillMetadata } from "./skills.js"; export type { WorkflowMetadata } from "./workflows.js"; export type { SubAgentMetadata } from "./agents.js"; diff --git a/src/index.ts b/src/index.ts index 2c09693..7688504 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { expandSkillCommand, refreshSkills } from "./skills.js"; import { loadHooksConfig, runHooks, wrapToolWithHooks } from "./hooks.js"; import type { HooksConfig } from "./hooks.js"; import { loadDeclarativeTools } from "./tool-loader.js"; +import { setupMcp } from "./mcp/manager.js"; import { toAgentTool } from "./tool-utils.js"; import { AuditLogger, isAuditEnabled } from "./audit.js"; import { formatComplianceWarnings } from "./compliance.js"; @@ -541,6 +542,10 @@ async function main(): Promise { } } + // MCP tools (manifest-declared servers) + const mcpSetup = await setupMcp(manifest.mcp_servers, existingToolNames); + tools.push(...mcpSetup.tools); + // Wrap with hooks if configured if (hooksConfig) { tools = tools.map((t) => wrapToolWithHooks(t, hooksConfig, agentDir, sessionId)); @@ -633,6 +638,7 @@ async function main(): Promise { } throw err; } finally { + await mcpSetup.cleanup().catch(() => {}); if (localSession) { console.log(dim("Finalizing session...")); localSession.finalize(); @@ -667,6 +673,7 @@ async function main(): Promise { if (trimmed === "/quit" || trimmed === "/exit") { rl.close(); + await mcpSetup.cleanup().catch(() => {}); if (localSession) { console.log(dim("Finalizing session...")); localSession.finalize(); @@ -818,7 +825,7 @@ async function main(): Promise { try { _session.end({ "gitagent.cost_usd": _totalCostUsd }); } catch { /* ignore */ } - stopSandbox().finally(() => process.exit(0)); + Promise.all([mcpSetup.cleanup(), stopSandbox()]).finally(() => process.exit(0)); } }); diff --git a/src/loader.ts b/src/loader.ts index b8d193e..3fe06ba 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -22,6 +22,7 @@ import type { ComplianceWarning } from "./compliance.js"; import { discoverAndLoadPlugins } from "./plugins.js"; import type { LoadedPlugin } from "./plugin-types.js"; import type { PluginConfig } from "./plugin-types.js"; +import type { McpServerConfig } from "./mcp/types.js"; export interface AgentManifest { spec_version: string; @@ -55,6 +56,7 @@ export interface AgentManifest { delegation?: { mode: "auto" | "explicit" | "router"; router?: string }; compliance?: Record; plugins?: Record; + mcp_servers?: Record; } async function readFileOr(path: string, fallback: string): Promise { diff --git a/src/mcp/manager.ts b/src/mcp/manager.ts new file mode 100644 index 0000000..c54ac09 --- /dev/null +++ b/src/mcp/manager.ts @@ -0,0 +1,254 @@ +import { buildTool } from "../tool-factory.js"; +import { interpolateEnv } from "../env-utils.js"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { McpServerConfig, McpStdioServerConfig, McpSetupResult } from "./types.js"; + +const DEFAULT_TIMEOUT_MS = 30000; + +/** A live connection to one MCP server. */ +interface McpConnection { + name: string; + client: Client; + close: () => Promise; +} + +function withTimeout(op: Promise, ms: number, label: string): Promise { + return Promise.race([ + op, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms), + ), + ]); +} + +/** + * Flatten an MCP tool result (an array of content blocks plus an optional + * `isError` flag) into the plain string that an AgentTool's execute() returns. + * Binary blocks are summarized rather than inlined to protect the token budget. + */ +export function flattenToolResult(result: any): string { + const blocks: any[] = Array.isArray(result?.content) ? result.content : []; + const parts: string[] = []; + for (const block of blocks) { + switch (block?.type) { + case "text": + parts.push(String(block.text ?? "")); + break; + case "image": + parts.push(`[image: ${block.mimeType || "unknown"}, data omitted]`); + break; + case "audio": + parts.push(`[audio: ${block.mimeType || "unknown"}, data omitted]`); + break; + case "resource": { + const res = block.resource ?? {}; + if (typeof res.text === "string") parts.push(res.text); + else parts.push(`[resource: ${res.uri || "unknown"} (${res.mimeType || "binary"})]`); + break; + } + case "resource_link": + parts.push(`[resource_link: ${block.uri || "unknown"}]`); + break; + default: + if (block?.text) parts.push(String(block.text)); + break; + } + } + let text = parts.join("\n"); + // Some tools (those with an outputSchema) return only `structuredContent` + // with no text blocks — fall back to its JSON so the model isn't handed "". + if (!text && result?.structuredContent !== undefined) { + try { + text = JSON.stringify(result.structuredContent); + } catch { + /* leave text empty if not serializable */ + } + } + return result?.isError ? `Error: ${text}` : text; +} + +/** + * Make a tool name safe for LLM provider APIs, which require names to match + * roughly `^[a-zA-Z0-9_-]{1,64}$` (OpenAI/Anthropic). Invalid characters become + * `_`; names longer than 64 chars are truncated. Collisions after truncation + * are caught by the existing-name check in setupMcp. + */ +function sanitizeToolName(name: string): string { + const cleaned = name.replace(/[^a-zA-Z0-9_-]/g, "_"); + return cleaned.length > 64 ? cleaned.slice(0, 64) : cleaned; +} + +/** List every tool a server offers, following pagination cursors to the end. */ +async function listAllTools(client: Client): Promise { + const all: any[] = []; + let cursor: string | undefined; + do { + const res = await client.listTools(cursor ? { cursor } : undefined); + all.push(...res.tools); + cursor = res.nextCursor; + } while (cursor); + return all; +} + +/** + * List a connected server's tools and convert each into an AgentTool. + * Tools are namespaced `__` (sanitized for provider name rules) + * to avoid collisions. Exported so tests can exercise conversion over an + * in-memory transport without spawning. + */ +export async function buildToolsForConnection(conn: McpConnection): Promise[]> { + const tools = await listAllTools(conn.client); + return tools.map((t) => + buildTool({ + name: sanitizeToolName(`${conn.name}__${t.name}`), + description: t.description || `MCP tool ${t.name} from ${conn.name}`, + // MCP inputSchema is a JSON-Schema object ({type, properties, required}). + // buildTypeboxSchema converts it recursively (integers, enums, typed + // arrays, nested objects); unknown shapes degrade to Type.Any. + parameters: (t.inputSchema as Record) ?? {}, + execute: async (args: any, signal?: AbortSignal) => { + try { + // Forward the agent's abort signal so cancelling the turn cancels + // the in-flight MCP request. Call with the original (unsanitized) name. + const result = await conn.client.callTool( + { name: t.name, arguments: args ?? {} }, + undefined, + { signal }, + ); + return flattenToolResult(result); + } catch (err: any) { + // Protocol-level errors (e.g. -32602) throw; surface them as a + // string so the agent can read and recover instead of crashing. + return `Error: ${err?.message || err}`; + } + }, + // We can't know a remote tool's semantics — fail closed. + metadata: { isConcurrencySafe: false, isReadOnly: false, isDestructive: false }, + }), + ); +} + +/** Connect to a single server. Fail-soft: logs and returns null on any error. */ +async function connectServer(name: string, rawCfg: McpServerConfig): Promise { + const cfg = interpolateEnv(rawCfg); + const timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS; + try { + const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); + + let transport: any; + if (cfg.type === "http") { + if (!cfg.url) throw new Error("http server requires a url"); + const { StreamableHTTPClientTransport } = await import( + "@modelcontextprotocol/sdk/client/streamableHttp.js" + ); + transport = new StreamableHTTPClientTransport(new URL(cfg.url), { + requestInit: cfg.headers ? { headers: cfg.headers } : undefined, + }); + } else if (cfg.type === "sse") { + if (!cfg.url) throw new Error("sse server requires a url"); + const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js"); + transport = new SSEClientTransport(new URL(cfg.url), { + requestInit: cfg.headers ? { headers: cfg.headers } : undefined, + }); + } else { + const stdio = cfg as McpStdioServerConfig; + if (!stdio.command) throw new Error("stdio server requires a command"); + const { StdioClientTransport, getDefaultEnvironment } = await import( + "@modelcontextprotocol/sdk/client/stdio.js" + ); + transport = new StdioClientTransport({ + command: stdio.command, + args: stdio.args ?? [], + env: { ...getDefaultEnvironment(), ...(stdio.env ?? {}) }, + cwd: stdio.cwd, + }); + } + + const client = new Client({ name: "gitagent", version: "1.5.2" }, { capabilities: {} }); + try { + await withTimeout(client.connect(transport), timeoutMs, `[mcp:${name}] connect`); + } catch (connectErr) { + // connect() spawns the child process / opens the socket. If it fails or + // times out mid-handshake, close the transport so we don't leak the + // spawned process or connection. + try { await transport?.close?.(); } catch { /* best-effort */ } + throw connectErr; + } + + return { + name, + client, + close: async () => { + try { + await client.close(); + } catch { + /* best-effort */ + } + }, + }; + } catch (err: any) { + console.warn(`[mcp:${name}] failed to connect: ${err?.message || err} — skipping`); + return null; + } +} + +/** + * Connect to all configured MCP servers, register their tools, and return an + * idempotent cleanup. Servers connect in parallel and independently fail-soft — + * one bad server never blocks the others. Returns immediately with no work (and + * without loading the MCP SDK) when no servers are configured. + * + * @param servers merged server map (manifest + SDK options) + * @param existingToolNames running set of tool names already registered; used + * for collision detection and updated in place. + */ +export async function setupMcp( + servers: Record | undefined, + existingToolNames: Set, +): Promise { + const entries = Object.entries(servers ?? {}); + if (entries.length === 0) { + return { tools: [], cleanup: async () => {} }; + } + + const settled = await Promise.allSettled( + entries.map(([name, cfg]) => connectServer(name, cfg)), + ); + const connections = settled + .map((s) => (s.status === "fulfilled" ? s.value : null)) + .filter((c): c is McpConnection => c !== null); + + const tools: AgentTool[] = []; + for (const conn of connections) { + let built: AgentTool[]; + try { + built = await withTimeout( + buildToolsForConnection(conn), + DEFAULT_TIMEOUT_MS, + `[mcp:${conn.name}] listTools`, + ); + } catch (err: any) { + console.warn(`[mcp:${conn.name}] failed to list tools: ${err?.message || err} — skipping`); + await conn.close(); + continue; + } + for (const tool of built) { + if (existingToolNames.has(tool.name)) { + console.warn(`[mcp:${conn.name}] tool "${tool.name}" collides with an existing tool — skipping`); + continue; + } + existingToolNames.add(tool.name); + tools.push(tool); + } + } + + let closed = false; + const cleanup = async () => { + if (closed) return; + closed = true; + await Promise.allSettled(connections.map((c) => c.close())); + }; + + return { tools, cleanup }; +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts new file mode 100644 index 0000000..a609d93 --- /dev/null +++ b/src/mcp/types.ts @@ -0,0 +1,31 @@ +import type { AgentTool } from "@mariozechner/pi-agent-core"; + +/** A local MCP server launched as a child process, spoken to over stdio. */ +export interface McpStdioServerConfig { + type?: "stdio"; + command: string; + args?: string[]; + env?: Record; + cwd?: string; + /** Connect + initial listTools timeout in ms. Default 30000. */ + timeoutMs?: number; +} + +/** A remote MCP server reached over Streamable HTTP (or legacy SSE). */ +export interface McpHttpServerConfig { + type: "http" | "sse"; + url: string; + headers?: Record; + /** Connect + initial listTools timeout in ms. Default 30000. */ + timeoutMs?: number; +} + +export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig; + +/** Result of wiring up all configured MCP servers for a session. */ +export interface McpSetupResult { + /** Tools discovered across all servers, namespaced `__`. */ + tools: AgentTool[]; + /** Idempotent teardown — closes every transport/client. Safe to call repeatedly. */ + cleanup: () => Promise; +} diff --git a/src/plugins.ts b/src/plugins.ts index 8a22899..5ab6171 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -4,6 +4,7 @@ import { execFileSync } from "child_process"; import { createRequire } from "module"; import { homedir } from "os"; import yaml from "js-yaml"; +import { interpolateEnvString } from "./env-utils.js"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { PluginManifest, @@ -73,7 +74,7 @@ function resolvePluginConfig( let value = userConfig[key]; // Resolve ${ENV_VAR} syntax if (typeof value === "string") { - value = value.replace(/\$\{(\w+)\}/g, (_, envName) => process.env[envName] || ""); + value = interpolateEnvString(value); } resolved[key] = value; } else if (prop.env && process.env[prop.env]) { diff --git a/src/sdk-types.ts b/src/sdk-types.ts index 20dc060..5ab79ca 100644 --- a/src/sdk-types.ts +++ b/src/sdk-types.ts @@ -1,5 +1,6 @@ import type { AgentManifest } from "./loader.js"; import type { SessionCosts } from "./cost-tracker.js"; +import type { McpServerConfig } from "./mcp/types.js"; // ── Message types ────────────────────────────────────────────────────── @@ -145,6 +146,8 @@ export interface QueryOptions { maxTurns?: number; abortController?: AbortController; sessionId?: string; + /** MCP servers to connect to. Merged with manifest `mcp_servers` (these win on key collision). */ + mcpServers?: Record; constraints?: { temperature?: number; maxTokens?: number; diff --git a/src/sdk.ts b/src/sdk.ts index dfa9a80..55b941c 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -9,6 +9,8 @@ import type { SandboxContext } from "./sandbox.js"; import { loadHooksConfig, runHooks, wrapToolWithHooks } from "./hooks.js"; import { loadDeclarativeTools } from "./tool-loader.js"; import { toAgentTool } from "./tool-utils.js"; +import { setupMcp } from "./mcp/manager.js"; +import type { McpSetupResult } from "./mcp/types.js"; import { wrapToolWithProgrammaticHooks } from "./sdk-hooks.js"; import { mergeHooksConfigs } from "./plugins.js"; import { initLocalSession } from "./session.js"; @@ -109,6 +111,7 @@ export function query(options: QueryOptions): Query { // Sandbox context (hoisted for cleanup in catch) let sandboxCtx: SandboxContext | undefined; + let mcpSetup: McpSetupResult | undefined; // Local session (hoisted for cleanup in catch) let localSession: LocalSession | undefined; @@ -206,6 +209,11 @@ export function query(options: QueryOptions): Query { } } + // MCP tools — merge manifest + SDK server configs (SDK wins on key collision) + const mcpServers = { ...loaded.manifest.mcp_servers, ...options.mcpServers }; + mcpSetup = await setupMcp(mcpServers, existingToolNames); + tools = [...tools, ...mcpSetup.tools]; + // SDK-provided tools if (options.tools) { const converted = options.tools.map(toAgentTool); @@ -550,6 +558,12 @@ export function query(options: QueryOptions): Query { // Ensure channel finishes even if no agent_end event channel.finish(); } finally { + // Tear down MCP servers on every exit path — success, hook-block + // early-return, abort, and error (this finally runs before the + // .catch() handler below). cleanup() is idempotent. + if (mcpSetup) { + try { await mcpSetup.cleanup(); } catch { /* best-effort */ } + } // Close the session span on every exit path — success, hook-block // early-return, and the .catch() handler below (rethrow so this // runs first). diff --git a/src/tool-loader.ts b/src/tool-loader.ts index 5c58f02..13ac32d 100644 --- a/src/tool-loader.ts +++ b/src/tool-loader.ts @@ -16,35 +16,67 @@ interface ToolDefinition { }; } -export function buildTypeboxSchema(schema: Record): any { - // Convert a simplified JSON-schema-like object to Typebox properties +/** + * Recursively convert a JSON-Schema node into a TypeBox schema. Handles the + * full set of types tool authors and MCP servers use: string/number/integer/ + * boolean/null, enums (→ union of literals), typed arrays (real item type), + * nested objects (real properties + required), and union `type` arrays. Unknown + * or missing types degrade to `Type.Any()` so the call still goes through and + * the server validates the actual payload. + */ +function jsonSchemaToTypebox(def: any): any { + if (!def || typeof def !== "object") return Type.Any(); + const desc = def.description || ""; + const opts = desc ? { description: desc } : {}; + + // enum → union of literals (preserves the allowed values for the model) + if (Array.isArray(def.enum) && def.enum.length > 0) { + if (def.enum.length === 1) return Type.Literal(def.enum[0], opts); + return Type.Union(def.enum.map((v: any) => Type.Literal(v)), opts); + } + + // union type, e.g. ["string", "null"] + if (Array.isArray(def.type)) { + const variants = def.type.map((t: string) => jsonSchemaToTypebox({ ...def, type: t, description: undefined })); + return variants.length === 1 ? variants[0] : Type.Union(variants, opts); + } + + switch (def.type) { + case "string": + return Type.String(opts); + case "number": + return Type.Number(opts); + case "integer": + return Type.Integer(opts); + case "boolean": + return Type.Boolean(opts); + case "null": + return Type.Null(opts); + case "array": + return Type.Array(def.items ? jsonSchemaToTypebox(def.items) : Type.Any(), opts); + case "object": + return buildTypeboxSchema(def, opts); + default: + // No/unknown type — fall back to permissive Any. + return Type.Any(opts); + } +} + +/** + * Build a TypeBox object schema from a JSON-Schema-like object (with + * `properties` and `required`). Used for both declarative YAML tools and MCP + * tool input schemas. + */ +export function buildTypeboxSchema(schema: Record, opts: Record = {}): any { const properties: Record = {}; if (schema.properties) { for (const [key, def] of Object.entries(schema.properties) as [string, any][]) { - const desc = def.description || ""; const required = schema.required?.includes(key) ?? false; - let prop; - switch (def.type) { - case "number": - prop = Type.Number({ description: desc }); - break; - case "boolean": - prop = Type.Boolean({ description: desc }); - break; - case "array": - prop = Type.Array(Type.Any(), { description: desc }); - break; - case "object": - prop = Type.Any({ description: desc }); - break; - default: - prop = Type.String({ description: desc }); - break; - } + const prop = jsonSchemaToTypebox(def); properties[key] = required ? prop : Type.Optional(prop); } } - return Type.Object(properties); + return Type.Object(properties, opts); } function createDeclarativeTool( diff --git a/test/mcp.test.ts b/test/mcp.test.ts new file mode 100644 index 0000000..746b92d --- /dev/null +++ b/test/mcp.test.ts @@ -0,0 +1,167 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { z } from "zod"; + +// Dynamic imports since the project is ESM and tests run against compiled dist. +let buildToolsForConnection: typeof import("../dist/mcp/manager.js").buildToolsForConnection; +let flattenToolResult: typeof import("../dist/mcp/manager.js").flattenToolResult; +let setupMcp: typeof import("../dist/mcp/manager.js").setupMcp; +let Client: typeof import("@modelcontextprotocol/sdk/client/index.js").Client; +let McpServer: typeof import("@modelcontextprotocol/sdk/server/mcp.js").McpServer; +let InMemoryTransport: typeof import("@modelcontextprotocol/sdk/inMemory.js").InMemoryTransport; + +before(async () => { + const mgr = await import("../dist/mcp/manager.js"); + buildToolsForConnection = mgr.buildToolsForConnection; + flattenToolResult = mgr.flattenToolResult; + setupMcp = mgr.setupMcp; + Client = (await import("@modelcontextprotocol/sdk/client/index.js")).Client; + McpServer = (await import("@modelcontextprotocol/sdk/server/mcp.js")).McpServer; + InMemoryTransport = (await import("@modelcontextprotocol/sdk/inMemory.js")).InMemoryTransport; +}); + +/** Stand up an in-memory MCP server exposing echo/boom/pic tools, return a connected Client. */ +async function connectInMemoryServer() { + const server = new McpServer({ name: "test-server", version: "1.0.0" }); + + server.registerTool( + "echo", + { description: "Echo a message", inputSchema: { msg: z.string() } }, + async ({ msg }) => ({ content: [{ type: "text", text: msg }] }), + ); + server.registerTool( + "boom", + { description: "Always errors", inputSchema: {} }, + async () => ({ content: [{ type: "text", text: "kaboom" }], isError: true }), + ); + server.registerTool( + "pic", + { description: "Returns an image", inputSchema: {} }, + async () => ({ content: [{ type: "image", data: "AAAA", mimeType: "image/png" }] }), + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + return { client, server }; +} + +describe("flattenToolResult", () => { + it("joins text blocks", () => { + assert.equal(flattenToolResult({ content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }), "a\nb"); + }); + it("prefixes Error: when isError is true", () => { + assert.equal(flattenToolResult({ content: [{ type: "text", text: "bad" }], isError: true }), "Error: bad"); + }); + it("summarizes image blocks without inlining data", () => { + const out = flattenToolResult({ content: [{ type: "image", data: "AAAA", mimeType: "image/png" }] }); + assert.equal(out, "[image: image/png, data omitted]"); + assert.ok(!out.includes("AAAA")); + }); + it("handles embedded resource text and links", () => { + assert.equal(flattenToolResult({ content: [{ type: "resource", resource: { uri: "file://x", text: "hi" } }] }), "hi"); + assert.equal(flattenToolResult({ content: [{ type: "resource_link", uri: "file://y" }] }), "[resource_link: file://y]"); + }); + it("tolerates empty/malformed results", () => { + assert.equal(flattenToolResult({}), ""); + assert.equal(flattenToolResult(null), ""); + }); + it("falls back to structuredContent JSON when there are no text blocks", () => { + assert.equal( + flattenToolResult({ content: [], structuredContent: { count: 42 } }), + '{"count":42}', + ); + }); + it("prefers text content over structuredContent when both exist", () => { + assert.equal( + flattenToolResult({ content: [{ type: "text", text: "hi" }], structuredContent: { x: 1 } }), + "hi", + ); + }); +}); + +describe("buildToolsForConnection", () => { + it("namespaces tool names and wires execute through callTool", async () => { + const { client } = await connectInMemoryServer(); + const conn = { name: "srv", client, close: async () => { await client.close(); } }; + const tools = await buildToolsForConnection(conn as any); + + const names = tools.map((t) => t.name).sort(); + assert.deepEqual(names, ["srv__boom", "srv__echo", "srv__pic"]); + + const echo = tools.find((t) => t.name === "srv__echo")!; + const res = await echo.execute("call-1", { msg: "hello" }); + assert.equal(res.content[0].text, "hello"); + + const boom = tools.find((t) => t.name === "srv__boom")!; + const boomRes = await boom.execute("call-2", {}); + assert.match(boomRes.content[0].text, /^Error: /); + + const pic = tools.find((t) => t.name === "srv__pic")!; + const picRes = await pic.execute("call-3", {}); + assert.match(picRes.content[0].text, /\[image: image\/png/); + + await conn.close(); + }); +}); + +describe("buildToolsForConnection — pagination & name sanitization", () => { + it("follows nextCursor across pages and sanitizes/caps tool names", async () => { + // A mock client that paginates listTools across two pages. + const longName = "x".repeat(100); + const mockClient: any = { + async listTools(params: any) { + if (!params?.cursor) { + return { tools: [{ name: "do.it", inputSchema: {} }], nextCursor: "page2" }; + } + return { tools: [{ name: longName, inputSchema: {} }] }; // no nextCursor → end + }, + async callTool() { + return { content: [{ type: "text", text: "ok" }] }; + }, + }; + const tools = await buildToolsForConnection({ name: "srv", client: mockClient, close: async () => {} }); + + // both pages collected + assert.equal(tools.length, 2); + // invalid char "." sanitized to "_" + assert.equal(tools[0].name, "srv__do_it"); + // long name capped to 64 and only valid chars + assert.ok(tools[1].name.length <= 64); + assert.match(tools[1].name, /^[a-zA-Z0-9_-]+$/); + }); + + it("returns a clean 'Error:' string when callTool throws (protocol error)", async () => { + const mockClient: any = { + async listTools() { + return { tools: [{ name: "bad", inputSchema: {} }] }; + }, + async callTool() { + throw new Error("MCP error -32602: Invalid arguments"); + }, + }; + const tools = await buildToolsForConnection({ name: "srv", client: mockClient, close: async () => {} }); + const res = await tools[0].execute("c1", {}); + assert.match((res.content[0] as any).text, /^Error: .*-32602/); + }); +}); + +describe("setupMcp", () => { + it("returns empty tools and a no-op cleanup when no servers configured", async () => { + const result = await setupMcp(undefined, new Set()); + assert.deepEqual(result.tools, []); + // cleanup is idempotent / safe to call repeatedly + await result.cleanup(); + await result.cleanup(); + }); + + it("fails soft on an unreachable server (bad command) without throwing", async () => { + const existing = new Set(); + const result = await setupMcp( + { broken: { command: "definitely-not-a-real-binary-xyz", args: [], timeoutMs: 2000 } }, + existing, + ); + assert.deepEqual(result.tools, []); + await result.cleanup(); + }); +}); From 737e90a651c850800875ae5fbe46a077af8f4db2 Mon Sep 17 00:00:00 2001 From: Nivesh353 Date: Thu, 18 Jun 2026 16:51:49 +0530 Subject: [PATCH 2/4] Scaffold gitagent agent --- docs/firstsource-session.md | 1263 +++++++++++++++++++++++++++++++++++ docs/serverless-blog.md | 113 ++++ 2 files changed, 1376 insertions(+) create mode 100644 docs/firstsource-session.md create mode 100644 docs/serverless-blog.md diff --git a/docs/firstsource-session.md b/docs/firstsource-session.md new file mode 100644 index 0000000..1b04261 --- /dev/null +++ b/docs/firstsource-session.md @@ -0,0 +1,1263 @@ +# GitAgent Developer Training Session +### First Source Dev Team Onboarding +**Duration:** ~2 hours | **Format:** Live walkthrough + hands-on exercise + +--- + +> **Facilitator note:** This document is your script, demo guide, and reference sheet in one. Each section includes talking points (what to say), demo steps (what to show), and key concepts to drive home. Code blocks are copy-paste ready for live demos. + +--- + +## Pre-session Checklist + +Before you start, make sure every participant has: + +- [ ] Node.js 20+ installed (`node --version`) +- [ ] npm installed (`npm --version`) +- [ ] git installed (`git --version`) +- [ ] Terminal access (macOS/Linux/WSL) +- [ ] At least one API key ready: Anthropic, OpenAI, or a Lyzr Studio agent ID + +--- + +## Section 1 — Introduction (5 min) + +### What to say + +"Most agent frameworks treat your AI configuration like application code — scattered across files, environment variables, and framework-specific APIs. GitAgent flips that completely. + +In GitAgent, **your agent IS a git repository**. The personality is a markdown file. The rules are a markdown file. The memory is a markdown file that gets committed every time the agent remembers something. The tools, the skills, the hooks — all files in a repo you can clone, fork, branch, and diff. + +Think about what that actually gives you. You can `git log` your agent's memory and see exactly how it evolved. You can `git diff` to see when a rule changed. You can branch off a 'strict-mode' version of your agent for production and a more experimental one for testing. You can fork a teammate's agent, inherit their entire personality and toolset, and customize from there. That's 'agents as repos' — and it's a fundamentally different mental model. + +For a dev team like yours, this is powerful because you already know git. Every workflow you use for code — PRs, branch protection, CI checks — works exactly the same for your agents." + +### Key points to emphasize + +- The core insight: **the agent IS the git repo**, not code that describes an agent +- Git primitives (fork, diff, log, branch) become agent primitives +- No framework lock-in — configuration is plain text files + +### What to show + +Open a terminal and show the structure of a running GitAgent repo: + +``` +my-agent/ +├── agent.yaml # The manifest — model, tools, runtime config +├── SOUL.md # Personality and identity +├── RULES.md # Behavioral constraints +├── DUTIES.md # Job responsibilities +├── AGENTS.md # Sub-agent relationships +├── memory/ +│ └── MEMORY.md # Primary memory (auto-committed by the agent) +├── skills/ +│ └── my-skill/ +│ ├── SKILL.md # Skill definition +│ └── scripts/ # Supporting scripts +├── hooks/ +│ └── hooks.yaml # Lifecycle hooks +└── tools/ + └── *.yaml # Declarative tool definitions +``` + +"This is everything your agent needs to exist. Back it up, share it, version it, deploy it — it's just a directory." + +--- + +## Section 2 — Installation & First Agent (15 min) + +### What to say + +"Let's get everyone running. There are two ways to install. The fastest is a one-command installer that handles everything interactively — API key setup, scaffolding, and launching the web UI." + +### What to show + +#### Option A: One-command install (recommended for today) + +```bash +bash <(curl -fsSL "https://raw.githubusercontent.com/open-gitagent/gitagent/main/install.sh?$(date +%s)") +``` + +"That curl-bash will: +1. Install `@open-gitagent/gitagent` globally via npm (the slim CLI + SDK) +2. Install `@open-gitagent/voice` for the web UI at `localhost:3333` +3. Walk you through API key setup in interactive mode +4. Launch the web UI in your browser" + +**Requirements:** Node.js 18+ (20+ recommended), npm, git + +#### Option B: Manual install (for CI/sandboxed environments) + +```bash +# Core CLI + SDK only (no voice, no web UI — good for headless/CI) +npm install -g @open-gitagent/gitagent + +# Add voice mode + web UI +npm install -g @open-gitagent/voice +``` + +"If your security scanner flags the full install, use the slim core. It's about 85KB vs 180KB and has no third-party scanner triggers." + +#### Scaffold your first agent + +```bash +# Create a directory for your agent +mkdir ~/my-first-agent && cd ~/my-first-agent + +# Run gitagent in it — it auto-scaffolds everything on first run +export OPENAI_API_KEY="sk-..." # or ANTHROPIC_API_KEY, or LYZR_API_KEY +gitagent "Hello, what are you?" +``` + +"Watch what happens. GitAgent detects there's no `agent.yaml`, so it scaffolds one along with `SOUL.md`, `RULES.md`, and `memory/MEMORY.md` automatically. Then it answers your question." + +#### Walk through what was created + +```bash +ls -la ~/my-first-agent +cat ~/my-first-agent/agent.yaml +cat ~/my-first-agent/SOUL.md +cat ~/my-first-agent/RULES.md +cat ~/my-first-agent/memory/MEMORY.md +``` + +#### Launch the web UI + +```bash +gitagent --voice # Opens localhost:3333 in your browser +``` + +"The web UI has tabs for Chat, Skills, Integrations, Communication, SkillFlows, Scheduler, and Settings. We'll come back to several of these. For now, confirm everyone can open `localhost:3333`." + +### Key points to emphasize + +- Auto-scaffolding means zero manual setup to get started +- The slim install (`GITAGENT_SLIM=1`) skips voice for pipeline/CI use cases +- The web UI is optional — everything works headlessly too + +--- + +## Section 3 — The #1 Question: Connecting to Lyzr Studio (10 min) + +### What to say + +"Before we go deeper into configuration, I want to address the question we get more than any other: 'How do I connect GitAgent to Lyzr Studio?' This is probably relevant to several of you, so let's do it now. + +Lyzr Studio lets you build, manage, and orchestrate AI agents visually. GitAgent can use a Lyzr Studio agent as its model backend — meaning the intelligence comes from your Studio agent, and GitAgent provides the git-native structure, tools, memory, and hooks around it." + +### What to show — step by step + +#### Step 1: Get your LYZR_API_KEY + +1. Go to [https://studio.lyzr.ai](https://studio.lyzr.ai) and log in +2. Navigate to **Settings → API Keys** +3. Copy your API key + +```bash +export LYZR_API_KEY="lyzr-sk-..." +``` + +#### Step 2: Get your Agent ID + +1. In Lyzr Studio, open the agent you want to connect +2. The agent ID is in the URL: `https://studio.lyzr.ai/agents//...` +3. Or find it in the agent's **Settings** panel — it looks like `agent-abc123xyz` + +#### Step 3: Set the model in agent.yaml + +```yaml +# agent.yaml +spec_version: "0.1.0" +name: firstsource-agent +version: 0.1.0 +description: First Source's GitAgent connected to Lyzr Studio + +model: + preferred: "lyzr:agent-abc123xyz@https://agent-prod.studio.lyzr.ai/v4" + fallback: + - "openai:gpt-4o" # optional fallback if Studio is unreachable + +tools: + - cli + - read + - write + - memory + +runtime: + max_turns: 40 +``` + +The model string format is: `lyzr:@` + +#### Step 4: Run with explicit flags (if you prefer not to edit agent.yaml yet) + +```bash +gitagent \ + --model "lyzr:agent-abc123xyz@https://agent-prod.studio.lyzr.ai/v4" \ + "Hello from GitAgent" +``` + +#### Step 5: Verify the connection + +You should see the response come from your Lyzr Studio agent. Check the Studio dashboard — the agent's invocation count should increment. + +### Common errors and fixes + +| Error | Likely cause | Fix | +|---|---|---| +| `401 Unauthorized` | Wrong or missing API key | Check `echo $LYZR_API_KEY` is set correctly | +| `404 Not Found` | Wrong agent ID in the model string | Verify the agent ID from Studio URL | +| `Model provider not found: lyzr` | Outdated gitagent version | `npm install -g @open-gitagent/gitagent@latest` | +| Agent responds but ignores SOUL.md | Studio agent has its own system prompt | Either merge them in Studio, or use `systemPromptSuffix` in SDK | +| Timeout on first call | Studio agent cold start | Retry once; subsequent calls are faster | + +### What to say (wrap-up) + +"Once that's working, everything else we cover today — memory, skills, hooks — wraps around your Lyzr Studio agent. The Studio agent provides the intelligence; GitAgent provides the structure and control layer." + +--- + +## Section 4 — Configuring Your Agent (15 min) + +### What to say + +"Now let's understand what you can actually configure. The starting point is always `agent.yaml` — it's the manifest that describes everything about your agent. But `agent.yaml` mostly wires things together. The real character of your agent lives in the identity files." + +### What to show + +#### agent.yaml — full reference example + +```yaml +# agent.yaml +spec_version: "0.1.0" +name: firstsource-support-agent +version: 1.0.0 +description: Customer support agent for First Source + +# Model configuration +model: + preferred: "anthropic:claude-sonnet-4-5-20250929" + fallback: + - "openai:gpt-4o" + - "google:gemini-2.0-flash" + +# Built-in tools to enable +tools: + - cli + - read + - write + - memory + - task_tracker + +# Runtime limits +runtime: + max_turns: 40 + +# MCP servers (covered in Section 8) +mcp_servers: + github: + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PAT}" + +# Compliance (covered in Section 9) +compliance: + risk_level: medium + human_in_the_loop: false + audit_logging: true + regulatory_frameworks: [SOC2] + +# Sub-agents (optional — covered later) +agents: + researcher: + dir: "./agents/researcher" + delegation: + mode: explicit +``` + +**Multi-provider model strings:** + +```yaml +# Anthropic +preferred: "anthropic:claude-sonnet-4-5-20250929" + +# OpenAI +preferred: "openai:gpt-4o" + +# Google +preferred: "google:gemini-2.0-flash" + +# Groq (fast inference) +preferred: "groq:llama-3.3-70b-versatile" + +# Local via Ollama +preferred: "ollama:llama3.2" + +# Local via LM Studio +preferred: "lmstudio:mistral-7b" + +# Lyzr Studio +preferred: "lyzr:agent-abc123@https://agent-prod.studio.lyzr.ai/v4" +``` + +#### SOUL.md — writing a good personality + +"SOUL.md is your agent's personality file. It defines who the agent is — how it speaks, what it cares about, how it approaches problems. It becomes part of the system prompt on every query." + +**What makes a good SOUL.md:** + +```markdown +# Alex — First Source Support Agent + +You are Alex, a senior customer support specialist at First Source Financial Services. You've been with the company for five years and you know the product inside out. + +## How you work + +- You respond concisely and directly. Support tickets aren't the place for preamble. +- You ask one clarifying question at a time — never a list of five questions at once. +- When you don't know something, you say so, then point to where the answer can be found. +- You use the ticket tracker to log every resolution step so teammates can pick up mid-thread. + +## Tone + +- Professional but human. You're not a bot — you're a specialist. +- Calm under pressure. Escalations don't fluster you. +- Never overpromise. If you say "I'll check on that," you check on it. + +## Knowledge domain + +You specialize in: account management, billing disputes, integration support, and API troubleshooting. +``` + +"Notice: no emoji, no corporate speak, concrete behaviors. Write it the way you'd brief a new hire on their first day." + +#### RULES.md — behavioral constraints + +"RULES.md is where you put hard constraints — things the agent must never do, always do, or require explicit approval for." + +```markdown +# Rules + +1. **Never share customer PII in responses.** Redact account numbers, SSNs, and contact details from any output visible to third parties. +2. **Read before modifying.** Always read a file before editing or overwriting it. +3. **Require approval for external API calls.** Any outbound HTTP request to a non-approved domain needs confirmation. +4. **No credentials in memory.** Never store API keys, tokens, or passwords in MEMORY.md. +5. **Escalate unresolved issues after 3 turns.** If a customer issue isn't resolved within three exchanges, create an escalation ticket and notify a human. +6. **Stay in scope.** Only operate within the current repository and approved external services. +``` + +#### DUTIES.md — job responsibilities + +"DUTIES.md describes the agent's recurring responsibilities — what it's supposed to proactively do, what workflows it owns." + +```markdown +# Duties + +## Daily responsibilities +- Review open support tickets and triage by severity +- Check integration health dashboards and flag anomalies +- Update MEMORY.md with any new resolution patterns discovered + +## On each new ticket +1. Classify: billing, access, integration, or other +2. Check MEMORY.md for a matching prior resolution +3. Attempt resolution; document steps taken +4. If resolved: close ticket and log pattern to memory +5. If unresolved after 3 turns: escalate per RULES.md +``` + +### Key points to emphasize + +- `agent.yaml` is the wiring; identity files are the character +- SOUL.md is read on every query — keep it focused and specific +- RULES.md constraints are enforced via the agent's reasoning, not code — keep rules unambiguous +- These files are committed to git, so you get a full audit trail of every personality change + +--- + +## Section 5 — Tools & Skills (15 min) + +### What to say + +"Tools are the actions your agent can take. Skills are composable instruction modules — think of them as prompts-plus-scripts you can snap in and invoke on demand." + +### What to show + +#### Built-in tools + +| Tool | What it does | +|---|---| +| `cli` | Run any shell command | +| `read` | Read files from the filesystem | +| `write` | Write or create files | +| `memory` | Save to `memory/MEMORY.md` (auto-commits) | +| `capture_photo` | Take a photo via webcam | +| `task_tracker` | Create and update tasks | +| `skill_learner` | Learn and save new skills automatically | + +Enable them in `agent.yaml`: + +```yaml +tools: + - cli + - read + - write + - memory + - task_tracker +``` + +Or restrict them from the SDK: + +```typescript +import { query } from "gitagent"; + +for await (const msg of query({ + prompt: "Summarize the logs", + dir: "./my-agent", + allowedTools: ["read", "memory"], // whitelist + disallowedTools: ["cli"], // or blacklist +})) { + if (msg.type === "delta") process.stdout.write(msg.content); +} +``` + +#### Creating your first custom skill + +"Skills live in `skills//SKILL.md`. The frontmatter registers the skill; the markdown body becomes the agent's instructions when the skill is invoked." + +```bash +mkdir -p ~/my-first-agent/skills/summarize-pr +``` + +Create `skills/summarize-pr/SKILL.md`: + +```markdown +--- +name: summarize-pr +description: Summarizes a GitHub pull request — what changed, why, and risk level. +--- + +# Summarize Pull Request + +When this skill is invoked: + +1. Ask for the PR number if not provided. +2. Use the `cli` tool to run: `gh pr view --json title,body,files,additions,deletions` +3. Analyze the diff for: + - What problem it solves + - What files changed and why + - Estimated risk level: Low / Medium / High + - Any obvious issues or missing test coverage +4. Output a summary in this format: + +**PR #: ** +- **What:** <one sentence> +- **Why:** <one sentence> +- **Changed:** <N files, +X -Y lines> +- **Risk:** Low / Medium / High +- **Flags:** <any issues, or "None"> +``` + +#### Invoke skills from the REPL + +```bash +# In the gitagent REPL or web UI chat: +/skill:summarize-pr Review PR #142 +``` + +#### Skills with supporting scripts + +```bash +mkdir -p ~/my-first-agent/skills/run-tests/scripts +``` + +Create `skills/run-tests/SKILL.md`: + +```markdown +--- +name: run-tests +description: Runs the project test suite and summarizes failures. +--- + +# Run Tests + +Execute the test script and report results: + +```bash +bash scripts/run.sh +``` + +Summarize: how many passed, how many failed, and what the failures are. +``` + +Create `skills/run-tests/scripts/run.sh`: + +```bash +#!/usr/bin/env bash +npm test 2>&1 | tail -30 +``` + +Scripts receive args as JSON on stdin and return output on stdout. + +#### Automatic skill learning + +"The `skill_learner` built-in tool is interesting. When you enable it, the agent can learn new skills from conversation and save them automatically with a confidence score. If you show it how to do something once, it can codify that as a reusable skill." + +```yaml +# agent.yaml +tools: + - cli + - read + - write + - memory + - skill_learner # enables automatic skill capture +``` + +"The agent won't just save anything — it assigns confidence scores and only promotes high-confidence patterns to permanent skills. Lower confidence entries stay as memory notes until they're validated through repeated use." + +### Key points to emphasize + +- Skills are version-controlled — you can review, rollback, or branch skill changes +- A skill is just markdown + optional scripts, so anyone on the team can write or edit one +- `/skill:name` invocation works in both the REPL and the web UI chat + +--- + +## Section 6 — Memory System (10 min) + +### What to say + +"Memory in GitAgent is unlike any other framework I've seen. Most agents use a vector database or hidden in-memory state. GitAgent's memory is a markdown file in your repo that the agent commits every time it saves something. Your agent's memory has a git history. + +That means you can `git log memory/MEMORY.md` and see every memory entry in order. You can `git diff HEAD~5 memory/MEMORY.md` to see exactly what the agent remembered over the last five runs. You can `git revert` to roll back a bad memory. You can fork a repo and give the fork a completely different memory history. This is extraordinarily powerful for debugging, auditing, and collaboration." + +### What to show + +#### Primary memory — MEMORY.md + +```bash +cat ~/my-first-agent/memory/MEMORY.md +``` + +"Every time the agent calls the `memory` tool, it appends to this file and creates a git commit. No external database required." + +Example of what an agent writes to memory: + +```markdown +# Agent Memory + +## Resolved Patterns + +### Billing dispute: duplicate charge +- Root cause: race condition in payment processor webhook +- Resolution: Void the duplicate, issue credit note, flag account for 30-day monitoring +- First seen: 2025-06-10, recurred: 2025-06-14 + +### API auth failure: 401 on valid token +- Root cause: Token cached before timezone-offset expiry recalculation +- Resolution: Force token refresh + advise client to add 5-min buffer to expiry +``` + +#### Memory layers via memory.yaml + +For more advanced use, you can define layered memory: + +```yaml +# memory/memory.yaml +layers: + - name: primary + path: memory/MEMORY.md + description: Core working memory + - name: journal + path: memory/journal.md + description: Daily activity log + - name: mood + path: memory/mood.md + description: Current agent state and context +``` + +#### Why git-native memory is powerful + +```bash +# See full memory history +git log --oneline memory/MEMORY.md + +# See what the agent remembered in the last 10 runs +git diff HEAD~10 memory/MEMORY.md + +# Roll back a bad memory entry +git revert <commit-hash> + +# Fork the repo, fork the memory history +git checkout -b experiment +# edit SOUL.md, run agent, memory diverges independently +``` + +"In a team context: if two people fork the same agent and run it for a week, you can literally `git merge` their memory histories. Try doing that with a vector database." + +### Key points to emphasize + +- Memory is plain text + git, not a hidden opaque database +- `git log` on memory = full audit trail of agent decisions +- Layered memory lets you separate short-term working memory from long-term patterns +- Auto-archiving keeps MEMORY.md from growing unbounded — the agent summarizes old entries + +--- + +## Section 7 — Hooks for Control & Safety (10 min) + +### What to say + +"Hooks are how you put guardrails on your agent without having to modify its core behavior. A hook fires at a specific lifecycle event — before a tool runs, after a failure, when a file changes — and it can block, modify, or allow the action. + +This is critical for production deployments. You probably don't want an agent that can run `rm -rf` on your production server, even if it thinks it's a good idea. Hooks let you enforce that at the infrastructure level." + +### What to show + +#### Hook events reference + +| Event | Fires when | Can block? | +|---|---|---| +| `pre_tool_use` | Before any tool executes | Yes | +| `post_tool_failure` | After a tool fails | No (logging) | +| `pre_query` | Before sending to LLM | Yes | +| `post_response` | After LLM responds | No (logging) | +| `file_changed` | A tracked file is modified | Yes | +| `on_error` | Any unhandled error | No (logging) | + +#### Script-based hooks (hooks/hooks.yaml) + +```bash +mkdir -p ~/my-first-agent/hooks +``` + +Create `hooks/hooks.yaml`: + +```yaml +hooks: + pre_tool_use: + - script: hooks/safety-check.sh + description: Block dangerous commands and require approval for deployments + + post_response: + - script: hooks/audit-log.sh + description: Log all responses to audit trail + + on_error: + - script: hooks/alert.sh + description: Alert team on unhandled errors +``` + +Create `hooks/safety-check.sh`: + +```bash +#!/usr/bin/env bash + +# Read context from stdin +CONTEXT=$(cat) +TOOL=$(echo "$CONTEXT" | jq -r '.tool // .toolName // ""') +COMMAND=$(echo "$CONTEXT" | jq -r '.args.command // ""') + +# Block rm -rf under any circumstances +if echo "$COMMAND" | grep -qE 'rm\s+-rf|rm\s+--recursive\s+-f'; then + echo '{"action":"block","reason":"Destructive rm -rf is not permitted. Use trash or move to a backup location instead."}' + exit 0 +fi + +# Block git push --force to main/master +if echo "$COMMAND" | grep -qE 'git push.*--force.*(main|master)|git push.*-f.*(main|master)'; then + echo '{"action":"block","reason":"Force push to main/master is not permitted. Open a PR."}' + exit 0 +fi + +# Require human approval for deploy commands +if echo "$COMMAND" | grep -qE 'kubectl apply|helm upgrade|terraform apply|fly deploy'; then + echo '{"action":"block","reason":"Production deployments require human approval. Use the deployment checklist PR flow."}' + exit 0 +fi + +# Everything else: allow +echo '{"action":"allow"}' +``` + +```bash +chmod +x ~/my-first-agent/hooks/safety-check.sh +``` + +#### Programmatic hooks via SDK (for inline use) + +```typescript +import { query } from "gitagent"; + +for await (const msg of query({ + prompt: "Deploy the new service version", + dir: "./my-agent", + hooks: { + preToolUse: async (ctx) => { + // Block destructive commands + if (ctx.toolName === "cli") { + const cmd = ctx.args.command ?? ""; + if (/rm\s+-rf/.test(cmd)) { + return { action: "block", reason: "Destructive rm -rf blocked by policy" }; + } + // Require approval for deploys + if (/kubectl apply|helm upgrade/.test(cmd)) { + return { action: "block", reason: "Deploy requires human approval via PR" }; + } + } + + // Rewrite unsafe file writes to a sandboxed path + if (ctx.toolName === "write" && !ctx.args.path.startsWith("/workspace/")) { + return { + action: "modify", + args: { ...ctx.args, path: `/workspace/${ctx.args.path}` }, + }; + } + + return { action: "allow" }; + }, + + onError: async (ctx) => { + // Send alert — could call a webhook here + console.error(`[ALERT] Agent error: ${ctx.error}`); + }, + }, +})) { + if (msg.type === "delta") process.stdout.write(msg.content); +} +``` + +"The three hook return values are: +- `{ action: 'allow' }` — proceed normally +- `{ action: 'block', reason: '...' }` — stop the tool call, show reason to agent +- `{ action: 'modify', args: {...} }` — let the tool run but with different arguments" + +### Key points to emphasize + +- Hooks are the safety layer between the agent and the world +- `pre_tool_use` is the most important hook — it runs before any tool executes +- Scripts are simpler for ops teams; programmatic hooks are better for complex conditional logic +- Hooks compose — you can have multiple scripts registered for the same event + +--- + +## Section 8 — MCP Client Integration (10 min) + +### What to say + +"MCP stands for Model Context Protocol — it's an open standard for connecting AI models to external tools and data sources. Think of it like a plugin system that any MCP-compatible agent can use. + +GitAgent is an MCP client. Point it at any MCP server and that server's tools are automatically discovered and available to your agent, no integration code required. There's already a large ecosystem of ready-made MCP servers for GitHub, Slack, PostgreSQL, filesystem operations, web fetch, and more." + +### What to show + +#### Configure MCP servers in agent.yaml + +```yaml +# agent.yaml +mcp_servers: + # Local server launched as a child process (stdio transport) + github: + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PAT}" + timeoutMs: 30000 + + # Remote server over Streamable HTTP + analytics: + type: http + url: "https://mcp.yourcompany.com/mcp" + headers: + Authorization: "Bearer ${ANALYTICS_TOKEN}" + + # Legacy SSE transport (deprecated but still supported) + legacy-service: + type: sse + url: "https://old.example.com/sse" +``` + +#### How tool namespacing works + +"When GitAgent connects to the `github` MCP server, it discovers all the tools that server exposes and registers them as `github__<tool_name>`. So `read_file` becomes `github__read_file`, `create_pr` becomes `github__create_pr`. This prevents naming collisions when you have multiple MCP servers connected." + +``` +MCP server: github + └─ list_pulls → agent sees: github__list_pulls + └─ create_issue → agent sees: github__create_issue + └─ get_pull_request → agent sees: github__get_pull_request + +MCP server: analytics + └─ query → agent sees: analytics__query + └─ get_dashboard → agent sees: analytics__get_dashboard +``` + +#### Practical example: GitHub MCP server + +```bash +# Install the GitHub MCP server +npm install -g @modelcontextprotocol/server-github + +# Add to agent.yaml +``` + +```yaml +mcp_servers: + github: + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PAT}" +``` + +```bash +export GITHUB_PAT="ghp_yourtoken" + +# Now ask the agent something that requires GitHub +gitagent "List the open PRs on our main repo and summarize what each one is doing" +``` + +"The agent will call `github__list_pulls`, `github__get_pull_request`, etc., automatically — no code, no glue layer." + +#### MCP via the SDK + +```typescript +import { query } from "gitagent"; + +for await (const msg of query({ + prompt: "Summarize last week's signups from the database", + mcpServers: { + postgres: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-postgres", process.env.DB_URL!], + }, + }, +})) { + if (msg.type === "tool_use") console.log(`Calling: ${msg.toolName}`); + if (msg.type === "delta") process.stdout.write(msg.content); +} +``` + +"SDK-level `mcpServers` merge with `agent.yaml` `mcp_servers`. If there's a key collision, the SDK value wins. This lets you override per-query without touching the manifest." + +### Behavior guarantees to know about + +| Behavior | Detail | +|---|---| +| Fail-soft | A server that can't start is logged and skipped — other tools keep working | +| Namespaced | Tool names are prefixed with server name, cleaned to satisfy provider naming rules | +| Pagination | Servers that paginate tool lists are fully enumerated | +| Cleanup | Stdio child processes are shut down on every exit path | +| Lazy loading | If no MCP servers are configured, the MCP SDK is never loaded | + +### Key points to emphasize + +- MCP is the "npm for agent tools" — install a server, point your agent at it, done +- Namespacing (`server__tool`) prevents conflicts when using multiple servers +- Fail-soft means a broken MCP server won't take down your agent session + +--- + +## Section 9 — Going to Production (10 min) + +### What to say + +"At some point you're going to want to deploy an agent that runs continuously, handles real workloads, and operates in an audited environment. Let's cover the production-readiness checklist." + +### What to show + +#### 1. Password-protect the web UI + +```bash +# Set before launching +export GITAGENT_PASSWORD="your-secure-password" +gitagent --voice # Now localhost:3333 requires this password +``` + +"Anyone who can reach the web UI can chat with your agent. In production, either set a strong password, put it behind a VPN/reverse proxy, or don't expose the UI at all and use the CLI/SDK only." + +#### 2. Branch-based deployment strategy + +"Because your agent is a git repo, you can use branches exactly like you do for code." + +```bash +# main branch = production agent +git checkout main +gitagent --dir . "Help the customer" + +# feature branch = experiment safely +git checkout -b experiment/new-personality +# edit SOUL.md, RULES.md +gitagent --dir . "Test the new behavior" + +# Merge when ready — peer review the SOUL.md diff just like code review +git checkout main +git merge experiment/new-personality +``` + +"This means your agent changes go through code review. Someone changes RULES.md to remove a safety constraint? That's a diff in a PR. Your team reviews it. CI can run tests against it. You get the same safety net you have for application code." + +#### 3. Compliance and audit logging + +```yaml +# agent.yaml +compliance: + risk_level: high # low | medium | high + human_in_the_loop: true # pause and require human approval for high-risk actions + data_classification: confidential + regulatory_frameworks: [SOC2, GDPR, HIPAA] + recordkeeping: + audit_logging: true + retention_days: 90 +``` + +"Audit logs are written to `.gitagent/audit.jsonl` — JSONL format, one entry per tool invocation, with full traces. If you need to answer 'what did the agent do at 14:32 on June 15?' you have a complete record." + +#### 4. Schedules for recurring tasks + +Create a schedule file: + +```bash +mkdir -p ~/my-first-agent/schedules +``` + +Create `schedules/daily-triage.yaml`: + +```yaml +name: daily-triage +description: Morning ticket triage at 8 AM every weekday +cron: "0 8 * * 1-5" # 8 AM Mon–Fri +prompt: | + Review all open support tickets from the last 24 hours. + For each: classify severity, check MEMORY.md for prior similar issues, + and prepare a triage summary. Save the summary to memory. +enabled: true +``` + +"Manage schedules in the web UI under the **Scheduler** tab, or define them as YAML files in the `schedules/` directory. Cron syntax, one-time runs, and recurring are all supported." + +#### 5. E2B sandbox for untrusted code execution + +```bash +# Run agent in an isolated VM sandbox +gitagent --sandbox "Analyze this uploaded CSV and generate a report" +``` + +```yaml +# Or in agent.yaml for a specific environment config +runtime: + max_turns: 40 + sandbox: true +``` + +#### 6. Secrets management + +```bash +# .gitignore — this is non-negotiable +cat >> ~/.gitignore_global << 'EOF' +.env +.env.* +*.pem +*.key +secrets/ +EOF + +git config --global core.excludesfile ~/.gitignore_global +``` + +"And use the global env fallback for keys that apply to all your agents:" + +```bash +mkdir -p ~/.gitagent +echo 'ANTHROPIC_API_KEY=sk-ant-...' >> ~/.gitagent/.env +echo 'LYZR_API_KEY=lyzr-sk-...' >> ~/.gitagent/.env +``` + +"Keys in `~/.gitagent/.env` are available to all your agents without being in any individual repo. The web UI also lets you save keys via the Settings tab, and they auto-reload without restarting the server." + +### Key points to emphasize + +- Branch-based deployment means agent changes get the same review process as code +- Audit logs in `.gitagent/audit.jsonl` are your compliance paper trail +- `~/.gitagent/.env` keeps secrets out of individual repos +- Schedules let you turn an interactive agent into an autonomous worker + +--- + +## Section 10 — Hands-on Exercise (15 min) + +### What to say + +"Now it's your turn. Each person is going to create their own agent, give it a personality and rules, and write one custom skill. By the end of this exercise you'll have a working agent you can take back to your team." + +### Exercise steps + +#### Step 1: Create your agent directory (2 min) + +```bash +mkdir ~/firstsource-<yourname>-agent +cd ~/firstsource-<yourname>-agent +git init +``` + +#### Step 2: Create agent.yaml (2 min) + +```yaml +# agent.yaml +spec_version: "0.1.0" +name: <yourname>-agent +version: 0.1.0 +description: My First Source GitAgent + +model: + preferred: "anthropic:claude-sonnet-4-5-20250929" # or your preferred provider + fallback: + - "openai:gpt-4o" + +tools: + - cli + - read + - write + - memory + +runtime: + max_turns: 20 +``` + +#### Step 3: Write your SOUL.md (3 min) + +"Write a SOUL.md for yourself as if you were describing your working style to a new team member. Be specific — what do you care about, how do you communicate, what's your expertise." + +```markdown +# <Your Agent Name> + +You are <name>, a <role> at First Source. + +## How you work +- <3 specific behavioral traits> + +## Tone +- <How you communicate> + +## Domain expertise +- <What you know> +``` + +#### Step 4: Write your RULES.md (2 min) + +```markdown +# Rules + +1. **Read before modifying.** Always read a file before editing it. +2. **No credentials in memory.** Never store API keys or passwords. +3. **<Add one rule specific to your role>** +4. **Report failures honestly.** If something didn't work, say so. +``` + +#### Step 5: Create a custom skill (4 min) + +"Create a skill that's useful for your actual work. Here are some ideas: +- `standup-summary` — summarizes what you did today from git log + notes +- `code-review-checklist` — runs through a standard review checklist +- `ticket-template` — generates a properly formatted support ticket +- `api-health-check` — pings a list of endpoints and reports status" + +```bash +mkdir -p skills/my-skill +``` + +```markdown +--- +name: my-skill +description: <one sentence describing what this skill does> +--- + +# <Skill Name> + +When this skill is invoked: + +1. <Step one> +2. <Step two> +3. Output the result in this format: <format> +``` + +#### Step 6: Run your agent and invoke the skill (2 min) + +```bash +gitagent "Hello — tell me who you are" + +# Then invoke your skill: +# /skill:my-skill <input> +``` + +#### Share with the group + +"Once everyone has their skill working, take two minutes to share: what skill did you build, and what would it actually save you time on?" + +--- + +## Section 11 — Q&A (10 min) + +### Anticipated questions with answers + +**Q: Can multiple developers share one agent repo?** + +Yes — that's the point. Treat it like a shared service repo. Use branch protection on `main`, require PR reviews for changes to `SOUL.md`, `RULES.md`, or `hooks/`. Anyone can add skills on feature branches. + +**Q: How do I handle secrets in a shared agent repo?** + +Two approaches: +1. Use environment variable references in agent.yaml (`"${MY_KEY}"`) and have each developer set the var locally or in CI +2. Put team-level secrets in `~/.gitagent/.env` on each machine — never committed + +Never put actual key values in any tracked file. + +**Q: What happens when the agent runs out of turns?** + +It stops with a `max_turns` system message. The state (including memory) is preserved. You can resume the conversation by running the agent again — it'll read MEMORY.md and have context. + +**Q: Can I use GitAgent with our existing CI/CD pipeline?** + +Yes. The SDK is the right approach for CI integration: + +```typescript +// In your CI script +import { query } from "gitagent"; + +for await (const msg of query({ + prompt: `Review PR #${process.env.PR_NUMBER} for security issues`, + dir: "./agent", + model: "anthropic:claude-sonnet-4-5-20250929", + allowedTools: ["read", "cli"], // restrict for CI +})) { + if (msg.type === "assistant") console.log(msg.content); +} +``` + +**Q: How does SkillFlow work for multi-step workflows?** + +SkillFlows are YAML files that define multi-step workflows. They support `__approval_gate__` steps that pause execution and ping via Telegram or WhatsApp before continuing. Good for workflows where a human needs to review an intermediate result before the agent proceeds. Manage them in the web UI's **SkillFlows** tab. + +**Q: Can the agent talk to our internal tools, not just public MCP servers?** + +Yes. Write a simple MCP server that wraps your internal API (there are SDKs for Python, TypeScript, and more at [modelcontextprotocol.io](https://modelcontextprotocol.io)) and configure it as a `stdio` server in `agent.yaml`. It runs as a child process on the same machine — no public exposure needed. + +**Q: What's the difference between `DUTIES.md` and a scheduled task?** + +DUTIES.md tells the agent what it's responsible for conceptually — it shapes behavior during any session. A schedule actually triggers the agent to run at a specific time. You'd typically have related content in both: DUTIES.md says "you own daily triage", and a schedule actually runs the triage at 8 AM. + +**Q: Can I connect GitAgent to our Lyzr Studio agents programmatically in a script?** + +Yes: + +```typescript +import { query } from "gitagent"; + +for await (const msg of query({ + prompt: "Handle this support request", + model: `lyzr:${process.env.LYZR_AGENT_ID}@https://agent-prod.studio.lyzr.ai/v4`, + dir: "./my-agent", +})) { + if (msg.type === "delta") process.stdout.write(msg.content); +} +``` + +Set `LYZR_API_KEY` in the environment before running. + +--- + +## Quick Reference Card + +Save this for daily use: + +```bash +# Install +bash <(curl -fsSL "https://raw.githubusercontent.com/open-gitagent/gitagent/main/install.sh?$(date +%s)") +npm install -g @open-gitagent/gitagent @open-gitagent/voice + +# Run +gitagent "prompt" # run in current dir +gitagent --dir ~/my-agent "prompt" # specific dir +gitagent --model anthropic:claude-sonnet-4-5-20250929 "prompt" # override model +gitagent --voice # open web UI at localhost:3333 +gitagent --sandbox "prompt" # run in isolated VM + +# Lyzr Studio +export LYZR_API_KEY="lyzr-sk-..." +gitagent --model "lyzr:<agent-id>@https://agent-prod.studio.lyzr.ai/v4" "prompt" + +# Invoke a skill +/skill:my-skill <input> + +# Plugins +gitagent plugin install https://github.com/org/plugin.git +gitagent plugin list +gitagent plugin init my-plugin + +# Telemetry (optional) +OTEL_TRACES_EXPORTER=console gitagent "prompt" # print spans to stdout +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 gitagent "prompt" # Jaeger + +# Git memory inspection +git log --oneline memory/MEMORY.md # see memory history +git diff HEAD~5 memory/MEMORY.md # see recent memory changes +``` + +### Key environment variables + +| Variable | Purpose | +|---|---| +| `ANTHROPIC_API_KEY` | Anthropic / Claude | +| `OPENAI_API_KEY` | OpenAI | +| `GOOGLE_GENERATIVE_AI_API_KEY` | Google Gemini | +| `GROQ_API_KEY` | Groq | +| `LYZR_API_KEY` | Lyzr Studio | +| `GITHUB_TOKEN` | GitHub repo access | +| `GITAGENT_PASSWORD` | Web UI password | +| `GITAGENT_SLIM` | Set to `1` to skip voice on install | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OpenTelemetry collector URL | + +### Agent directory structure reference + +``` +my-agent/ +├── agent.yaml # Required: model, tools, runtime, compliance +├── SOUL.md # Personality and identity +├── RULES.md # Behavioral constraints +├── DUTIES.md # Recurring responsibilities +├── AGENTS.md # Sub-agent relationships +├── memory/ +│ ├── MEMORY.md # Primary memory (auto-committed) +│ ├── memory.yaml # Memory layer config (optional) +│ ├── mood.md # Agent state (optional) +│ └── journal.md # Activity log (optional) +├── skills/ +│ └── <name>/ +│ ├── SKILL.md # Skill definition (frontmatter + instructions) +│ └── scripts/ # Supporting scripts +├── hooks/ +│ └── hooks.yaml # Lifecycle hook scripts +├── tools/ +│ └── *.yaml # Declarative tool definitions +├── plugins/ +│ └── <name>/ # Local plugins +├── schedules/ +│ └── *.yaml # Cron schedule definitions +└── .gitagent/ + └── audit.jsonl # Audit log (when audit_logging: true) +``` + +--- + +## Resources + +- GitHub: [https://github.com/open-gitagent/gitagent](https://github.com/open-gitagent/gitagent) +- Lyzr Studio: [https://studio.lyzr.ai](https://studio.lyzr.ai) +- MCP servers: [https://modelcontextprotocol.io](https://modelcontextprotocol.io) +- Issues / support: [https://github.com/open-gitagent/gitagent/issues](https://github.com/open-gitagent/gitagent/issues) + +--- + +*Session prepared for First Source dev team onboarding — GitAgent v1.1.1+* diff --git a/docs/serverless-blog.md b/docs/serverless-blog.md new file mode 100644 index 0000000..16897bb --- /dev/null +++ b/docs/serverless-blog.md @@ -0,0 +1,113 @@ +# Kill the Server. Your AI Agent Belongs in Git. + +Most AI agents are servers. They sit idle, burning compute, waiting for the next request. This is not a technical requirement. It is a habit — and an expensive one. + +GitAgent is not an agent you run. It is an agent you invoke. + +--- + +## The Server Assumption + +When developers build AI agents, they reach for the same mental model they use for web applications: start a process, keep it alive, let it handle requests. The model feels natural because it is familiar. But it rests on an assumption that almost nobody questions. + +Agents need state. They need to remember what happened last time — what tasks are in progress, what decisions were made, what the user told them yesterday. If you kill the process, you lose the context. So the process stays alive. And a process that stays alive is a server. + +To make that server durable, teams add infrastructure. Redis for short-term memory. Postgres for long-term state. S3 for file storage. A message queue so the agent survives a restart. Before long, you have a distributed system — and a monthly bill — just to keep an agent's memory alive between conversations. + +The assumption nobody examined: state lives in a database, so compute must live near the database. + +GitAgent challenges this at the foundation. + +--- + +## Git Is Already a Database + +Every piece of state a GitAgent agent needs is stored in a git repository. Memory is a markdown file. Task history is a structured file. Audit logs are append-only. The agent's personality, goals, and behavioral constraints live in `agent.yaml` and `SOUL.md`. Everything that must survive a session is a file that git tracks. + +This is not a workaround. It is a deliberate architecture. + +Git is already distributed. It is already durable — commits are fsync'd to disk before returning. It is already versioned, meaning every state change has a timestamp, an author, and a reason. It is already replicated the moment you push to a remote. It has been battle-tested as a persistence layer by millions of teams for two decades. + +Every AI agent team building a custom state management layer is rebuilding something git already provides — worse, without the distribution, the versioning, or the auditability. + +The agent repo is not where the code lives. It is the database, the audit log, the memory store, and the deployment artifact simultaneously. + +--- + +## What Serverless Actually Means Here + +When you invoke GitAgent in single-shot mode, a precise lifecycle runs and terminates: + +The agent starts by cloning its repo and loading its identity from configuration files. It connects any declared tools and MCP servers. It runs the task. When the task completes, a `finally` block commits any state changes to git, pushes them to the remote, and exits. The process does not linger. Nothing idles. The compute existed for exactly as long as the work took. + +When you invoke it again — an hour later, a week later, on a different machine — it clones the same repo, reads the same memory file, and picks up exactly where it left off. The continuity of the agent is in git, not in a running process. The compute is disposable. The state is permanent. + +This is the inversion. Traditional agents keep compute alive to protect state. GitAgent makes state durable so compute can be ephemeral. + +--- + +## Memory That Survives Process Death + +The memory system is where this becomes concrete. + +GitAgent writes memory to a markdown file in the agent repo. Every save is a synchronous git commit — the write is durable before the function returns. If the process crashes after the commit, the memory is not lost. It is in git history. The next invocation reads the same file and continues. + +This is fundamentally different from in-memory state or a database that a long-running process manages. There is no connection to close, no transaction to roll back, no cache to warm. The state is just files. Files that git manages with the same reliability guarantees git has always provided. + +When memory grows large, older entries are archived automatically — moved to a dated archive file in the same atomic commit. The agent's knowledge base is self-managing without any server process watching over it. + +--- + +## $0 Between Runs + +The cost argument follows directly from the architecture. + +An always-on agent on a cloud VM costs money every hour, whether it processes one request or none. An agent on managed infrastructure — ECS, Cloud Run, Kubernetes — costs money to keep warm, to maintain availability, to replicate state. The infrastructure bill does not care whether your agent was useful today. + +A GitAgent agent costs nothing between invocations. You pay only for the seconds it is actually working. On GitHub Actions, the compute is not just cheap — for most usage patterns it is free entirely. The agent runs, commits its state, and the runner shuts down. There is no idle cost because there is no idle state. + +The agent repo itself is free on any public repository host. The memory, the skills, the audit log, the agent's entire history — stored at zero marginal cost in a git repository that would exist anyway. + +--- + +## Triggering an Ephemeral Agent + +Because the agent is stateless compute, any system that can run a command can trigger it. A GitHub Actions workflow on a schedule. A webhook handler in a serverless function. A CI pipeline step. A cron job on any machine. Even a developer running a one-liner from a terminal. + +The trigger mechanism does not matter because the agent does not care how it was invoked. It reads its state from the repo, does the work, commits the result, and exits. The scheduler is external infrastructure — managed, reliable, already paid for — not something the agent process has to maintain. + +This also means concurrent runs are naturally safe. Each session creates an isolated git branch. Ten parallel invocations produce ten branches, each with its own memory writes, none colliding with the others. Git's branching model gives you isolation without coordination, for free. + +--- + +## The Honest Caveat + +GitAgent ships with a built-in cron scheduler, accessible through its voice and web UI server. That scheduler runs inside a long-lived process — if you stop the server, scheduled jobs do not fire. + +This is the right trade-off for interactive use cases: a developer running a personal assistant locally, an agent that needs to respond to voice commands, a setup where sub-minute scheduling matters. + +For production workloads where reliability and cost matter — use an external scheduler to trigger single-shot runs. GitHub Actions, AWS EventBridge, GCP Cloud Scheduler, Render cron jobs — any of these will invoke the agent more reliably than an in-process scheduler, with no infrastructure to babysit, and with the agent's state persisting safely in git regardless. + +--- + +## The Bigger Shift + +The industry defaulted to always-on agents because it inherited the mental model of always-on services. But a service needs to be alive to handle requests. An agent needs to be capable — and capability lives in configuration files, memory, and learned skills, not in a running process. + +When you store agent state in git, you decouple capability from availability. The agent does not need to be running to exist. It does not need a server to remember. It does not need uptime to be useful. It needs a repo. + +This changes what it means to deploy an agent. There is no server to provision, no container to scale, no process to monitor. There is a repository. Fork it to create a new agent. Branch it to experiment. Tag it to pin a release. Push it to deploy. The entire operational model for AI agents collapses into git workflows that developers already know. + +You do not need infrastructure to run intelligence. You need a repo and a reason to invoke it. + +--- + +## Get Started + +```bash +npm install -g @open-gitagent/gitagent +gitagent --prompt "Hello from a serverless agent" +``` + +- Website: [gitagent.sh](https://gitagent.sh) +- Repo: [github.com/open-gitagent/gitagent](https://github.com/open-gitagent/gitagent) From 000d4ba585d17e0ba6c6db50efc706e9da113315 Mon Sep 17 00:00:00 2001 From: Nivesh353 <abhinivesh.s@lyzr.ai> Date: Tue, 23 Jun 2026 15:43:16 +0530 Subject: [PATCH 3/4] Scaffold gitagent agent --- GitAgent_Redesigned.pptx | Bin 0 -> 43080 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 GitAgent_Redesigned.pptx diff --git a/GitAgent_Redesigned.pptx b/GitAgent_Redesigned.pptx new file mode 100644 index 0000000000000000000000000000000000000000..7fa214c09093ee0d94f9782a3ac43f6c58802f84 GIT binary patch literal 43080 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 literal 0 HcmV?d00001 From ecf57cdc0a90e6848d2cbe7ccb2a692ce4e5d660 Mon Sep 17 00:00:00 2001 From: Nivesh353 <abhinivesh.s@lyzr.ai> Date: Fri, 26 Jun 2026 10:08:04 +0530 Subject: [PATCH 4/4] =?UTF-8?q?fix(mcp):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20test=20assertions,=20timer=20leak,=20env=20warn,=20?= =?UTF-8?q?version=20string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/env-utils.ts | 7 ++++++- src/mcp/manager.ts | 22 +++++++++++++++------- test/mcp.test.ts | 8 ++++---- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/env-utils.ts b/src/env-utils.ts index ca08d3b..799e7ee 100644 --- a/src/env-utils.ts +++ b/src/env-utils.ts @@ -10,7 +10,12 @@ const ENV_VAR_PATTERN = /\$\{(\w+)\}/g; export function interpolateEnvString(value: string): string { - return value.replace(ENV_VAR_PATTERN, (_, envName) => process.env[envName] || ""); + return value.replace(ENV_VAR_PATTERN, (_, envName) => { + if (process.env[envName] === undefined) { + console.warn(`[mcp] env var ${envName} is not set; substituting empty string`); + } + return process.env[envName] ?? ""; + }); } export function interpolateEnv<T>(value: T): T { diff --git a/src/mcp/manager.ts b/src/mcp/manager.ts index c54ac09..ac29d69 100644 --- a/src/mcp/manager.ts +++ b/src/mcp/manager.ts @@ -1,9 +1,13 @@ +import { createRequire } from "node:module"; import { buildTool } from "../tool-factory.js"; import { interpolateEnv } from "../env-utils.js"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import type { McpServerConfig, McpStdioServerConfig, McpSetupResult } from "./types.js"; +const _require = createRequire(import.meta.url); +const { version: PACKAGE_VERSION } = _require("../../package.json") as { version: string }; + const DEFAULT_TIMEOUT_MS = 30000; /** A live connection to one MCP server. */ @@ -14,12 +18,16 @@ interface McpConnection { } function withTimeout<T>(op: Promise<T>, ms: number, label: string): Promise<T> { - return Promise.race([ - op, - new Promise<T>((_, reject) => - setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms), - ), - ]); + return new Promise<T>((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${label} timed out after ${ms}ms`)), + ms, + ); + op.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); }, + ); + }); } /** @@ -165,7 +173,7 @@ async function connectServer(name: string, rawCfg: McpServerConfig): Promise<Mcp }); } - const client = new Client({ name: "gitagent", version: "1.5.2" }, { capabilities: {} }); + const client = new Client({ name: "gitagent", version: PACKAGE_VERSION }, { capabilities: {} }); try { await withTimeout(client.connect(transport), timeoutMs, `[mcp:${name}] connect`); } catch (connectErr) { diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 746b92d..c9efb17 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -91,15 +91,15 @@ describe("buildToolsForConnection", () => { const echo = tools.find((t) => t.name === "srv__echo")!; const res = await echo.execute("call-1", { msg: "hello" }); - assert.equal(res.content[0].text, "hello"); + assert.equal(res, "hello"); const boom = tools.find((t) => t.name === "srv__boom")!; const boomRes = await boom.execute("call-2", {}); - assert.match(boomRes.content[0].text, /^Error: /); + assert.match(boomRes, /^Error: /); const pic = tools.find((t) => t.name === "srv__pic")!; const picRes = await pic.execute("call-3", {}); - assert.match(picRes.content[0].text, /\[image: image\/png/); + assert.match(picRes, /\[image: image\/png/); await conn.close(); }); @@ -142,7 +142,7 @@ describe("buildToolsForConnection — pagination & name sanitization", () => { }; const tools = await buildToolsForConnection({ name: "srv", client: mockClient, close: async () => {} }); const res = await tools[0].execute("c1", {}); - assert.match((res.content[0] as any).text, /^Error: .*-32602/); + assert.match(res, /^Error: .*-32602/); }); });