From f4711d9b380326b8d47a29663d7c603f3dddf901 Mon Sep 17 00:00:00 2001 From: kannan983 Date: Sat, 10 Oct 2026 12:21:48 +0400 Subject: [PATCH 1/6] fix(app): expose fast-json-patch exports to Node ESM --- docker/Dockerfile.app | 1 + package.json | 3 +++ patches/fast-json-patch@3.1.1.patch | 25 +++++++++++++++++++++++++ pnpm-lock.yaml | 11 ++++++++--- 4 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 patches/fast-json-patch@3.1.1.patch diff --git a/docker/Dockerfile.app b/docker/Dockerfile.app index 0ae73b2..f57a802 100644 --- a/docker/Dockerfile.app +++ b/docker/Dockerfile.app @@ -16,6 +16,7 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps/app/package.json ./apps/app/ COPY packages/design-system/package.json ./packages/design-system/ COPY turbo.json ./ +COPY patches ./patches # Install dependencies RUN pnpm install --frozen-lockfile diff --git a/package.json b/package.json index d246f43..8dabbdb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "pnpm": { "overrides": { "esbuild": "^0.27.3" + }, + "patchedDependencies": { + "fast-json-patch@3.1.1": "patches/fast-json-patch@3.1.1.patch" } } } diff --git a/patches/fast-json-patch@3.1.1.patch b/patches/fast-json-patch@3.1.1.patch new file mode 100644 index 0000000..112ecea --- /dev/null +++ b/patches/fast-json-patch@3.1.1.patch @@ -0,0 +1,25 @@ +diff --git a/index.js b/index.js +index d53ed04..5f77dc7 100644 +--- a/index.js ++++ b/index.js +@@ -1,8 +1,20 @@ + var core = require("./commonjs/core.js"); + Object.assign(exports, core); ++// Node's ESM interop cannot see Object.assign exports; name them explicitly. ++exports.getValueByPointer = core.getValueByPointer; ++exports.applyOperation = core.applyOperation; ++exports.applyPatch = core.applyPatch; ++exports.applyReducer = core.applyReducer; ++exports.validator = core.validator; ++exports.validate = core.validate; ++exports._areEquals = core._areEquals; + + var duplex = require("./commonjs/duplex.js"); + Object.assign(exports, duplex); ++exports.unobserve = duplex.unobserve; ++exports.observe = duplex.observe; ++exports.generate = duplex.generate; ++exports.compare = duplex.compare; + + var helpers = require("./commonjs/helpers.js"); + exports.JsonPatchError = helpers.PatchError; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8161fe..17df15f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,11 @@ settings: overrides: esbuild: ^0.27.3 +patchedDependencies: + fast-json-patch@3.1.1: + hash: so77bikrpgbfx2t6ypz5l3w6ce + path: patches/fast-json-patch@3.1.1.patch + importers: .: @@ -5964,7 +5969,7 @@ snapshots: '@ag-ui/proto': 0.0.48 '@types/uuid': 10.0.0 compare-versions: 6.1.1 - fast-json-patch: 3.1.1 + fast-json-patch: 3.1.1(patch_hash=so77bikrpgbfx2t6ypz5l3w6ce) rxjs: 7.8.1 untruncate-json: 0.0.1 uuid: 11.1.0 @@ -5977,7 +5982,7 @@ snapshots: '@ag-ui/proto': 0.0.52 '@types/uuid': 10.0.0 compare-versions: 6.1.1 - fast-json-patch: 3.1.1 + fast-json-patch: 3.1.1(patch_hash=so77bikrpgbfx2t6ypz5l3w6ce) rxjs: 7.8.1 untruncate-json: 0.0.1 uuid: 11.1.0 @@ -9323,7 +9328,7 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-patch@3.1.1: {} + fast-json-patch@3.1.1(patch_hash=so77bikrpgbfx2t6ypz5l3w6ce): {} fast-json-stable-stringify@2.1.0: {} From 1c0898f0ae236c3821dbdd09205994a33dfb7e3c Mon Sep 17 00:00:00 2001 From: kannan983 Date: Sat, 10 Oct 2026 12:21:48 +0400 Subject: [PATCH 2/6] fix(agent): stream credential-scoped model output once --- apps/agent/src/model.py | 34 ++++++++++++++++++++++++++-------- apps/agent/tests/test_model.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/apps/agent/src/model.py b/apps/agent/src/model.py index 33725ab..c1e7062 100644 --- a/apps/agent/src/model.py +++ b/apps/agent/src/model.py @@ -6,6 +6,7 @@ from langchain.agents.middleware import AgentMiddleware from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.messages import AIMessageChunk +from langchain_core.runnables import RunnableBinding from pydantic import Field from langchain_anthropic import ChatAnthropic from langchain_core.language_models.chat_models import BaseChatModel @@ -146,31 +147,48 @@ def _target(self, kwargs): tool_options = options.pop("_credential_tool_options", {}) if tools is not None: model = model.bind_tools(tools, **tool_options) + # Call the provider's private methods so only this wrapper's run reports + # events. A public nested call inherits the graph's callbacks and streams + # every token and tool-call delta to AG-UI a second time. + if isinstance(model, RunnableBinding): + options = {**model.kwargs, **options} + model = model.bound return model, options def _generate(self, messages, stop=None, run_manager=None, **kwargs): with provider_error_boundary(): model, options = self._target(kwargs) - message = model.invoke(messages, stop=stop, **options) - return ChatResult(generations=[ChatGeneration(message=message)]) + return model._generate(messages, stop=stop, **options) async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs): with provider_error_boundary(): model, options = self._target(kwargs) - message = await model.ainvoke(messages, stop=stop, **options) - return ChatResult(generations=[ChatGeneration(message=message)]) + return await model._agenerate(messages, stop=stop, **options) def _stream(self, messages, stop=None, run_manager=None, **kwargs): with provider_error_boundary(): model, options = self._target(kwargs) - for chunk in model.stream(messages, stop=stop, **options): - yield ChatGenerationChunk(message=as_chunk(chunk)) + if not streams(model): + yield as_generation_chunk(model._generate(messages, stop=stop, **options)) + return + yield from model._stream(messages, stop=stop, **options) async def _astream(self, messages, stop=None, run_manager=None, **kwargs): with provider_error_boundary(): model, options = self._target(kwargs) - async for chunk in model.astream(messages, stop=stop, **options): - yield ChatGenerationChunk(message=as_chunk(chunk)) + if not streams(model): + yield as_generation_chunk(await model._agenerate(messages, stop=stop, **options)) + return + async for chunk in model._astream(messages, stop=stop, **options): + yield chunk + + +def streams(model): + return type(model)._stream is not BaseChatModel._stream or type(model)._astream is not BaseChatModel._astream + + +def as_generation_chunk(result): + return ChatGenerationChunk(message=as_chunk(result.generations[0].message)) def as_chunk(message): diff --git a/apps/agent/tests/test_model.py b/apps/agent/tests/test_model.py index b9fc29f..7b860a6 100644 --- a/apps/agent/tests/test_model.py +++ b/apps/agent/tests/test_model.py @@ -84,3 +84,32 @@ def test_missing_provider_key_has_actionable_error(monkeypatch, model, key, valu monkeypatch.setenv(key, value) with pytest.raises(ValueError, match=key): build_model() + + +def test_credential_scoped_model_streams_each_chunk_once(): + import asyncio + from langchain_core.language_models.fake_chat_models import GenericFakeChatModel + from langchain_core.messages import AIMessage + from langchain_core.runnables import RunnableLambda + from src.model import CredentialScopedModel + + class ToolFake(GenericFakeChatModel): + def bind_tools(self, tools, **kwargs): + return self.bind(tools=tools) + + inner = ToolFake(messages=iter([AIMessage(content="a b c")])) + # Bound tools wrap the provider in a RunnableBinding, as in the real agent. + wrapped = CredentialScopedModel(fallback=inner).bind_tools([{"name": "t"}]) + + # Like a LangGraph node: the parent run's callbacks are inherited by calls inside it. + async def node(_): + return await wrapped.ainvoke("hi") + + async def stream_events(): + return [ + e async for e in RunnableLambda(node).astream_events("x", version="v2") + if e["event"] == "on_chat_model_stream" + ] + + tokens = [e["data"]["chunk"].content for e in asyncio.run(stream_events())] + assert "".join(tokens) == "a b c" From 4b93ded879407d61d31533ae5ea10424ed3ffe30 Mon Sep 17 00:00:00 2001 From: kannan983 Date: Sat, 10 Oct 2026 12:25:42 +0400 Subject: [PATCH 3/6] feat(docker): add Docker Compose for app, agent and MCP --- apps/agent/Dockerfile | 14 ++++++++++++++ docker-compose.yml | 30 ++++++++++++++++++++++++++++++ docker/Dockerfile.app | 3 +++ docker/Dockerfile.mcp | 20 ++++++++++++++++++++ docs/getting-started.md | 11 +++++++++++ 5 files changed, 78 insertions(+) create mode 100644 apps/agent/Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile.mcp diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile new file mode 100644 index 0000000..757021f --- /dev/null +++ b/apps/agent/Dockerfile @@ -0,0 +1,14 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +WORKDIR /app +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + +COPY main.py ./ +COPY src ./src +COPY skills ./skills + +EXPOSE 8123 +CMD ["uv", "run", "--no-sync", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8123"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c23b468 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + agent: + build: apps/agent + env_file: apps/agent/.env + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8123/health')"] + interval: 30s + + mcp: + build: + context: . + dockerfile: docker/Dockerfile.mcp + ports: + - "127.0.0.1:3100:3100" + restart: unless-stopped + + app: + build: + context: . + dockerfile: docker/Dockerfile.app + args: + NEXT_PUBLIC_SERVER_KEYS: "true" + environment: + LANGGRAPH_DEPLOYMENT_URL: http://agent:8123 + MCP_SERVER_URL: http://mcp:3100/mcp + ports: + - "127.0.0.1:${APP_PORT:-3000}:3000" + depends_on: [agent, mcp] + restart: unless-stopped diff --git a/docker/Dockerfile.app b/docker/Dockerfile.app index f57a802..248c356 100644 --- a/docker/Dockerfile.app +++ b/docker/Dockerfile.app @@ -33,6 +33,9 @@ COPY --from=deps /app/packages/design-system/node_modules ./packages/design-syst # Copy source code COPY . . +# Inlined at build time; "true" skips the browser key prompt. +ARG NEXT_PUBLIC_SERVER_KEYS + # Enable pnpm RUN corepack enable && corepack prepare pnpm@latest --activate diff --git a/docker/Dockerfile.mcp b/docker/Dockerfile.mcp new file mode 100644 index 0000000..2e4c64a --- /dev/null +++ b/docker/Dockerfile.mcp @@ -0,0 +1,20 @@ +# The MCP depends on @repo/design-system, so build from the workspace root. +FROM node:22-alpine AS builder +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9 --activate +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY patches ./patches +COPY apps/mcp/package.json ./apps/mcp/ +COPY packages/design-system/package.json ./packages/design-system/ +RUN pnpm install --frozen-lockfile --filter open-intelligent-ui-mcp... +COPY packages/design-system ./packages/design-system +COPY apps/mcp ./apps/mcp +RUN pnpm exec turbo run build --filter=open-intelligent-ui-mcp + +FROM node:22-alpine +WORKDIR /app +COPY --from=builder /app ./ +WORKDIR /app/apps/mcp +USER node +EXPOSE 3100 +CMD ["node", "dist/index.js"] diff --git a/docs/getting-started.md b/docs/getting-started.md index 73fa6fc..ba65efb 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -40,9 +40,20 @@ Optional settings belong in `apps/app/.env.local` or the frontend process enviro | `RATE_LIMIT_ENABLED` | `false` | Per-IP runtime rate limit | | `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window | | `RATE_LIMIT_MAX` | `40` | Requests per window | +| `NEXT_PUBLIC_SERVER_KEYS` | Unset | Build-time; `true` skips the browser key prompt when the agent has its own keys | The root [.env.example](../.env.example) documents both services' settings; placing variables only in a root `.env` is not the setup described here. +## Run with Docker Compose + +To self-host the app, agent, and MCP server with the agent's own keys, fill in `apps/agent/.env` (see above) and run: + +```bash +docker compose up -d +``` + +The app listens on `http://localhost:3000` (set `APP_PORT` to change it) and the MCP server on `http://localhost:3100/mcp`; both bind to `127.0.0.1`. The compose build sets `NEXT_PUBLIC_SERVER_KEYS=true`, so chat uses the agent's `.env` keys without asking for browser keys. After editing `apps/agent/.env`, run `docker compose up -d agent` to apply it. + ## Start and verify ```bash From 58a08a5b8f0aa703d7c85586010fe95527840454 Mon Sep 17 00:00:00 2001 From: kannan983 Date: Sat, 10 Oct 2026 12:25:42 +0400 Subject: [PATCH 4/6] feat(app): allow server-held keys to skip the key prompt --- apps/app/src/components/chat/provider-keys.test.tsx | 7 +++++++ apps/app/src/components/chat/provider-keys.tsx | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/chat/provider-keys.test.tsx b/apps/app/src/components/chat/provider-keys.test.tsx index 6b69843..204acb0 100644 --- a/apps/app/src/components/chat/provider-keys.test.tsx +++ b/apps/app/src/components/chat/provider-keys.test.tsx @@ -97,4 +97,11 @@ describe("memory-only provider keys", () => { fireEvent.click(screen.getByText("Cancel")); expect(options?.signal?.aborted).toBe(true); }); + it("treats server-held keys as configured when NEXT_PUBLIC_SERVER_KEYS is true", () => { + vi.stubEnv("NEXT_PUBLIC_SERVER_KEYS", "true"); + render(); + // Gate passes, but no browser headers are sent: the agent uses its own .env keys. + expect(screen.getByTestId("active")).toBeEmptyDOMElement(); + vi.unstubAllEnvs(); + }); }); diff --git a/apps/app/src/components/chat/provider-keys.tsx b/apps/app/src/components/chat/provider-keys.tsx index 8840ee6..1309b53 100644 --- a/apps/app/src/components/chat/provider-keys.tsx +++ b/apps/app/src/components/chat/provider-keys.tsx @@ -27,7 +27,8 @@ export function ProviderKeysProvider({ children }: { children: ReactNode }) { return ( { setKeys(value); setKeysOpen(false); setSession((value) => value + 1); }, clear: () => { setKeys(null); newChat(); }, From 41ca9231fae5024ed3b25b682deb706a7c02e9d7 Mon Sep 17 00:00:00 2001 From: kannan983 Date: Sat, 10 Oct 2026 12:53:06 +0400 Subject: [PATCH 5/6] revert(app): drop fast-json-patch patch --- docker/Dockerfile.app | 1 - package.json | 3 --- patches/fast-json-patch@3.1.1.patch | 25 ------------------------- pnpm-lock.yaml | 11 +++-------- 4 files changed, 3 insertions(+), 37 deletions(-) delete mode 100644 patches/fast-json-patch@3.1.1.patch diff --git a/docker/Dockerfile.app b/docker/Dockerfile.app index f57a802..0ae73b2 100644 --- a/docker/Dockerfile.app +++ b/docker/Dockerfile.app @@ -16,7 +16,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps/app/package.json ./apps/app/ COPY packages/design-system/package.json ./packages/design-system/ COPY turbo.json ./ -COPY patches ./patches # Install dependencies RUN pnpm install --frozen-lockfile diff --git a/package.json b/package.json index 8dabbdb..d246f43 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,6 @@ "pnpm": { "overrides": { "esbuild": "^0.27.3" - }, - "patchedDependencies": { - "fast-json-patch@3.1.1": "patches/fast-json-patch@3.1.1.patch" } } } diff --git a/patches/fast-json-patch@3.1.1.patch b/patches/fast-json-patch@3.1.1.patch deleted file mode 100644 index 112ecea..0000000 --- a/patches/fast-json-patch@3.1.1.patch +++ /dev/null @@ -1,25 +0,0 @@ -diff --git a/index.js b/index.js -index d53ed04..5f77dc7 100644 ---- a/index.js -+++ b/index.js -@@ -1,8 +1,20 @@ - var core = require("./commonjs/core.js"); - Object.assign(exports, core); -+// Node's ESM interop cannot see Object.assign exports; name them explicitly. -+exports.getValueByPointer = core.getValueByPointer; -+exports.applyOperation = core.applyOperation; -+exports.applyPatch = core.applyPatch; -+exports.applyReducer = core.applyReducer; -+exports.validator = core.validator; -+exports.validate = core.validate; -+exports._areEquals = core._areEquals; - - var duplex = require("./commonjs/duplex.js"); - Object.assign(exports, duplex); -+exports.unobserve = duplex.unobserve; -+exports.observe = duplex.observe; -+exports.generate = duplex.generate; -+exports.compare = duplex.compare; - - var helpers = require("./commonjs/helpers.js"); - exports.JsonPatchError = helpers.PatchError; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17df15f..a8161fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,11 +7,6 @@ settings: overrides: esbuild: ^0.27.3 -patchedDependencies: - fast-json-patch@3.1.1: - hash: so77bikrpgbfx2t6ypz5l3w6ce - path: patches/fast-json-patch@3.1.1.patch - importers: .: @@ -5969,7 +5964,7 @@ snapshots: '@ag-ui/proto': 0.0.48 '@types/uuid': 10.0.0 compare-versions: 6.1.1 - fast-json-patch: 3.1.1(patch_hash=so77bikrpgbfx2t6ypz5l3w6ce) + fast-json-patch: 3.1.1 rxjs: 7.8.1 untruncate-json: 0.0.1 uuid: 11.1.0 @@ -5982,7 +5977,7 @@ snapshots: '@ag-ui/proto': 0.0.52 '@types/uuid': 10.0.0 compare-versions: 6.1.1 - fast-json-patch: 3.1.1(patch_hash=so77bikrpgbfx2t6ypz5l3w6ce) + fast-json-patch: 3.1.1 rxjs: 7.8.1 untruncate-json: 0.0.1 uuid: 11.1.0 @@ -9328,7 +9323,7 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-patch@3.1.1(patch_hash=so77bikrpgbfx2t6ypz5l3w6ce): {} + fast-json-patch@3.1.1: {} fast-json-stable-stringify@2.1.0: {} From 101f1308078c118d04afd879c1850ef0eb41c2ea Mon Sep 17 00:00:00 2001 From: kannan983 Date: Sat, 10 Oct 2026 12:53:13 +0400 Subject: [PATCH 6/6] fix(docker): drop patches copy from MCP image --- docker/Dockerfile.mcp | 1 - 1 file changed, 1 deletion(-) diff --git a/docker/Dockerfile.mcp b/docker/Dockerfile.mcp index 2e4c64a..1412ca2 100644 --- a/docker/Dockerfile.mcp +++ b/docker/Dockerfile.mcp @@ -3,7 +3,6 @@ FROM node:22-alpine AS builder WORKDIR /app RUN corepack enable && corepack prepare pnpm@9 --activate COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ -COPY patches ./patches COPY apps/mcp/package.json ./apps/mcp/ COPY packages/design-system/package.json ./packages/design-system/ RUN pnpm install --frozen-lockfile --filter open-intelligent-ui-mcp...