@ponythewhite/base-context 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/base-context-runtime/pyproject.toml +1 -1
- package/dist/base-context-runtime/src/rlm/mcp.py +39 -17
- package/dist/base-context-runtime/test/test_mcp.py +87 -1
- package/dist/base-context-runtime/uv.lock +1 -1
- package/dist/build-info.json +1 -1
- package/dist/bundle/{bundled-modules-PWOGKUQQ.js → bundled-modules-R5EAJ62K.js} +2 -2
- package/dist/bundle/{chunk-44PEHPOG.js → chunk-BV25CVRM.js} +322 -171
- package/dist/bundle/{chunk-IADCPVEI.js → chunk-XDJ56NJA.js} +26 -11
- package/dist/bundle/{cli-main-4DS7Y3PI.js → cli-main-OUKN743U.js} +2 -2
- package/dist/bundle/cli.js +1 -1
- package/dist/bundle/{main-SV7JLVEB.js → main-QPCXSW6X.js} +2 -2
- package/dist/cli/daemon-ps.d.ts.map +1 -1
- package/dist/cli/daemon-ps.js +1 -0
- package/dist/cli/daemon-ps.js.map +1 -1
- package/dist/core/auth-storage.d.ts +4 -0
- package/dist/core/auth-storage.d.ts.map +1 -1
- package/dist/core/auth-storage.js +77 -19
- package/dist/core/auth-storage.js.map +1 -1
- package/dist/core/kernel/bootstrap.d.ts.map +1 -1
- package/dist/core/kernel/bootstrap.js +5 -2
- package/dist/core/kernel/bootstrap.js.map +1 -1
- package/dist/core/mcp/mcp-manager.d.ts.map +1 -1
- package/dist/core/mcp/mcp-manager.js +2 -2
- package/dist/core/mcp/mcp-manager.js.map +1 -1
- package/dist/installer.mjs +5 -2
- package/dist/modes/daemon/daemon-supervisor-ownership.d.ts.map +1 -1
- package/dist/modes/daemon/daemon-supervisor-ownership.js +16 -16
- package/dist/modes/daemon/daemon-supervisor-ownership.js.map +1 -1
- package/dist/modes/interactive/auth-flows.d.ts.map +1 -1
- package/dist/modes/interactive/auth-flows.js +7 -6
- package/dist/modes/interactive/auth-flows.js.map +1 -1
- package/dist/modes/interactive/components/login-dialog.d.ts +3 -1
- package/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
- package/dist/modes/interactive/components/login-dialog.js +3 -1
- package/dist/modes/interactive/components/login-dialog.js.map +1 -1
- package/dist/modes/interactive/components/menu-panel.d.ts +1 -0
- package/dist/modes/interactive/components/menu-panel.d.ts.map +1 -1
- package/dist/modes/interactive/components/menu-panel.js +3 -0
- package/dist/modes/interactive/components/menu-panel.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +7 -5
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.4] - 2026-09-17
|
|
4
|
+
|
|
5
|
+
- Fixed first-time Python setup failing to find its newly installed uv when XDG data paths are configured.
|
|
6
|
+
|
|
7
|
+
## [1.0.3] - 2026-09-17
|
|
8
|
+
|
|
9
|
+
- Fixed late OAuth and API-key logins overwriting a newer login or undoing logout.
|
|
10
|
+
- Fixed configured MCP environment-token authentication falling back to stored credentials.
|
|
11
|
+
- Fixed MCP tool discovery to retrieve paginated inventories without partial results and protect cached schemas from caller edits.
|
|
12
|
+
- Masked API-key prompts and pasted sign-in codes and callback values while keeping browser sign-in links readable.
|
|
13
|
+
- Fixed provider logout reporting success when saved credentials could not be removed.
|
|
14
|
+
- Fixed shutdown admission loss after slow process scans or transient registry errors while the shutdown process still owns its record.
|
|
15
|
+
- Fixed source builds including the development Python virtual environment in release packages.
|
|
16
|
+
|
|
3
17
|
## [1.0.2] - 2026-09-17
|
|
4
18
|
|
|
5
19
|
- Fixed RPC requests waiting for a timeout after the agent process exits, and removed the shutdown delay for an already-exited process.
|
|
@@ -10,6 +10,7 @@ import re
|
|
|
10
10
|
import threading
|
|
11
11
|
import time
|
|
12
12
|
from contextlib import AsyncExitStack
|
|
13
|
+
from copy import deepcopy
|
|
13
14
|
from collections.abc import Awaitable, Callable
|
|
14
15
|
from typing import Any, TypeVar
|
|
15
16
|
|
|
@@ -20,6 +21,7 @@ __all__ = ["McpStartupError", "call_tool", "close", "list_tools", "reload"]
|
|
|
20
21
|
|
|
21
22
|
_DEFAULT_STARTUP_TIMEOUT = 20.0
|
|
22
23
|
_DEFAULT_CALL_TIMEOUT = 60.0
|
|
24
|
+
_MAX_TOOL_LIST_PAGES = 100
|
|
23
25
|
# Must stay strictly below the host's KERNEL_SHUTDOWN_TIMEOUT_MS (5s) kill deadline.
|
|
24
26
|
_SHUTDOWN_TIMEOUT = 2.5
|
|
25
27
|
_T = TypeVar("_T")
|
|
@@ -259,21 +261,41 @@ class _Generation:
|
|
|
259
261
|
return await self.stack.enter_async_context(stdio_client(params, errlog=self._stderr))
|
|
260
262
|
|
|
261
263
|
async def discover(self) -> None:
|
|
262
|
-
|
|
264
|
+
from mcp.types import PaginatedRequestParams
|
|
265
|
+
|
|
263
266
|
tools: dict[str, dict[str, Any]] = {}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
267
|
+
cursor: str | None = None
|
|
268
|
+
seen_cursors: set[str] = set()
|
|
269
|
+
for _ in range(_MAX_TOOL_LIST_PAGES):
|
|
270
|
+
response = (
|
|
271
|
+
await self.session.list_tools()
|
|
272
|
+
if cursor is None
|
|
273
|
+
else await self.session.list_tools(params=PaginatedRequestParams(cursor=cursor))
|
|
274
|
+
)
|
|
275
|
+
for tool in response.tools:
|
|
276
|
+
name = getattr(tool, "name", None)
|
|
277
|
+
if not isinstance(name, str):
|
|
278
|
+
continue
|
|
279
|
+
schema = getattr(tool, "input_schema", None)
|
|
280
|
+
if schema is None:
|
|
281
|
+
schema = getattr(tool, "inputSchema", None)
|
|
282
|
+
tools[name] = {
|
|
283
|
+
"name": name,
|
|
284
|
+
"description": getattr(tool, "description", "") or "",
|
|
285
|
+
"inputSchema": schema if isinstance(schema, dict) else {},
|
|
286
|
+
}
|
|
287
|
+
cursor = getattr(response, "next_cursor", None)
|
|
288
|
+
if cursor is None:
|
|
289
|
+
cursor = getattr(response, "nextCursor", None)
|
|
290
|
+
if cursor is None:
|
|
291
|
+
self.tools = tools
|
|
292
|
+
return
|
|
293
|
+
if not isinstance(cursor, str):
|
|
294
|
+
raise RuntimeError(f"MCP server '{self.server}' returned an invalid tools/list cursor")
|
|
295
|
+
if cursor in seen_cursors:
|
|
296
|
+
raise RuntimeError(f"MCP server '{self.server}' repeated a tools/list cursor")
|
|
297
|
+
seen_cursors.add(cursor)
|
|
298
|
+
raise RuntimeError(f"MCP server '{self.server}' exceeded the tools/list page limit")
|
|
277
299
|
|
|
278
300
|
def allows(self, tool: str) -> bool:
|
|
279
301
|
enabled = self.config.get("enabledTools")
|
|
@@ -379,7 +401,7 @@ class _Registry:
|
|
|
379
401
|
async def tools(self, server: str) -> list[dict[str, Any]]:
|
|
380
402
|
async def operation() -> list[dict[str, Any]]:
|
|
381
403
|
generation = await self._get(server)
|
|
382
|
-
return [
|
|
404
|
+
return [deepcopy(tool) for name, tool in generation.tools.items() if generation.allows(name)]
|
|
383
405
|
|
|
384
406
|
return await self._tracked(operation)
|
|
385
407
|
|
|
@@ -529,7 +551,7 @@ def _bound_auth(provider: str, config: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
529
551
|
async def _auth_identity(server: str, config: dict[str, Any]) -> str:
|
|
530
552
|
env_name = config.get("bearerTokenEnvVar")
|
|
531
553
|
token = os.environ.get(env_name, "").strip() if isinstance(env_name, str) else ""
|
|
532
|
-
if config.get("oauth") is True and not
|
|
554
|
+
if config.get("oauth") is True and not env_name:
|
|
533
555
|
provider = f"mcp:{server}"
|
|
534
556
|
cred = _bound_auth(provider, config)
|
|
535
557
|
expires = (cred or {}).get("expires")
|
|
@@ -556,7 +578,7 @@ async def _headers(server: str, config: dict[str, Any]) -> dict[str, str]:
|
|
|
556
578
|
return headers
|
|
557
579
|
env_name = config.get("bearerTokenEnvVar")
|
|
558
580
|
token = os.environ.get(env_name, "").strip() if isinstance(env_name, str) else ""
|
|
559
|
-
if config.get("oauth") is True and not
|
|
581
|
+
if config.get("oauth") is True and not env_name:
|
|
560
582
|
cred = _bound_auth(f"mcp:{server}", config)
|
|
561
583
|
token = _resolve_config_value(str((cred or {}).get("access") or (cred or {}).get("key") or ""))
|
|
562
584
|
if token:
|
|
@@ -15,7 +15,7 @@ from pathlib import Path
|
|
|
15
15
|
from types import SimpleNamespace
|
|
16
16
|
from unittest import mock
|
|
17
17
|
|
|
18
|
-
from mcp.types import CallToolResult, TextContent
|
|
18
|
+
from mcp.types import CallToolResult, PaginatedRequestParams, TextContent
|
|
19
19
|
from rlm import McpToolError, mcp
|
|
20
20
|
from rlm.mcp_base import _parse_result
|
|
21
21
|
|
|
@@ -104,6 +104,55 @@ class McpRegistryTest(unittest.TestCase):
|
|
|
104
104
|
generation = self.generation({"type": "http"}, [tool])
|
|
105
105
|
self.assertEqual(generation.tools["raw.tool/name"]["inputSchema"], schema)
|
|
106
106
|
|
|
107
|
+
def test_listing_detaches_nested_cached_schemas(self):
|
|
108
|
+
schema = {"type": "object", "properties": {"x": {"enum": ["original"]}}}
|
|
109
|
+
generation = self.generation({"type": "http"}, [SimpleNamespace(name="tool", inputSchema=schema)])
|
|
110
|
+
|
|
111
|
+
async def scenario():
|
|
112
|
+
with mock.patch.object(mcp._registry, "_get_locked", mock.AsyncMock(return_value=generation)):
|
|
113
|
+
first = await mcp.list_tools("svc")
|
|
114
|
+
first[0]["inputSchema"]["properties"]["x"]["enum"].append("caller mutation")
|
|
115
|
+
second = await mcp.list_tools("svc")
|
|
116
|
+
self.assertEqual(second[0]["inputSchema"], schema)
|
|
117
|
+
self.assertEqual(schema["properties"]["x"]["enum"], ["original"])
|
|
118
|
+
|
|
119
|
+
run(scenario())
|
|
120
|
+
|
|
121
|
+
def test_discovers_all_pages_with_sdk_params_and_cursor_aliases(self):
|
|
122
|
+
generation = mcp._Generation("svc", {"type": "http"})
|
|
123
|
+
generation.session = SimpleNamespace(list_tools=mock.AsyncMock(side_effect=[
|
|
124
|
+
SimpleNamespace(tools=[SimpleNamespace(name="first")], next_cursor=""),
|
|
125
|
+
SimpleNamespace(tools=[SimpleNamespace(name="second")], nextCursor="page-3"),
|
|
126
|
+
SimpleNamespace(tools=[SimpleNamespace(name="third")], next_cursor=None),
|
|
127
|
+
]))
|
|
128
|
+
run(generation.discover())
|
|
129
|
+
self.assertEqual(list(generation.tools), ["first", "second", "third"])
|
|
130
|
+
self.assertEqual(generation.session.list_tools.call_args_list, [
|
|
131
|
+
mock.call(),
|
|
132
|
+
mock.call(params=PaginatedRequestParams(cursor="")),
|
|
133
|
+
mock.call(params=PaginatedRequestParams(cursor="page-3")),
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
def test_failed_pagination_never_publishes_partial_inventory(self):
|
|
137
|
+
for case, cursors, message in (
|
|
138
|
+
("cycle", ["repeat", "repeat"], "repeated"),
|
|
139
|
+
("malformed", [17], "invalid"),
|
|
140
|
+
("excessive", ["page-2", "page-3"], "page limit"),
|
|
141
|
+
):
|
|
142
|
+
with self.subTest(case=case):
|
|
143
|
+
generation = self.generation({"type": "http"}, [SimpleNamespace(name="previous")])
|
|
144
|
+
previous = generation.tools
|
|
145
|
+
generation.session = SimpleNamespace(list_tools=mock.AsyncMock(side_effect=[
|
|
146
|
+
SimpleNamespace(tools=[SimpleNamespace(name="partial")], next_cursor=cursor)
|
|
147
|
+
for cursor in cursors
|
|
148
|
+
]))
|
|
149
|
+
with mock.patch.object(mcp, "_MAX_TOOL_LIST_PAGES", 2):
|
|
150
|
+
with self.assertRaisesRegex(RuntimeError, message):
|
|
151
|
+
run(generation.discover())
|
|
152
|
+
self.assertIs(generation.tools, previous)
|
|
153
|
+
self.assertNotIn("partial", generation.tools)
|
|
154
|
+
self.assertLessEqual(generation.session.list_tools.call_count, 2)
|
|
155
|
+
|
|
107
156
|
def test_sdk_result_aliases_preserve_structured_output_and_errors(self):
|
|
108
157
|
structured = CallToolResult(content=[], structuredContent={})
|
|
109
158
|
self.assertEqual(_parse_result(structured), {})
|
|
@@ -317,6 +366,43 @@ class McpRegistryTest(unittest.TestCase):
|
|
|
317
366
|
env = mcp._stdio_env({"credentialSource": "acp", "env": {"TOKEN": "task-secret"}})
|
|
318
367
|
self.assertEqual(env, {"PATH": "/bin", "TOKEN": "task-secret"})
|
|
319
368
|
|
|
369
|
+
def test_selected_environment_token_refuses_missing_values_before_connection(self):
|
|
370
|
+
config = {
|
|
371
|
+
"type": "http", "url": "https://srv.example/mcp", "oauth": True,
|
|
372
|
+
"bearerTokenEnvVar": "SELECTED_MCP_TOKEN",
|
|
373
|
+
"headers": {"Authorization": "Bearer configured-header"},
|
|
374
|
+
}
|
|
375
|
+
for token in (None, "", " "):
|
|
376
|
+
with self.subTest(token=token), mock.patch.dict(
|
|
377
|
+
os.environ, {} if token is None else {"SELECTED_MCP_TOKEN": token}, clear=True
|
|
378
|
+
), mock.patch.object(mcp, "host_request", new=mock.AsyncMock(return_value=config)) as request, mock.patch.object(
|
|
379
|
+
mcp, "_read_auth", side_effect=AssertionError("must not read stored OAuth")
|
|
380
|
+
), mock.patch.object(mcp._Generation, "_open_http", new=mock.AsyncMock()) as connect:
|
|
381
|
+
with self.assertRaisesRegex(RuntimeError, "credentials.*not available"):
|
|
382
|
+
run(mcp.list_tools("remote"))
|
|
383
|
+
with self.assertRaisesRegex(RuntimeError, "credentials.*not available"):
|
|
384
|
+
run(mcp._headers("remote", config))
|
|
385
|
+
request.assert_awaited_once_with("mcp.config", {"server": "remote"})
|
|
386
|
+
connect.assert_not_called()
|
|
387
|
+
self.assertEqual(mcp._registry._generations, {})
|
|
388
|
+
mcp._registry = mcp._Registry()
|
|
389
|
+
|
|
390
|
+
def test_selected_environment_token_is_the_only_auth_source(self):
|
|
391
|
+
config = {
|
|
392
|
+
"oauth": True, "url": "https://srv.example/mcp", "bearerTokenEnvVar": "SELECTED_MCP_TOKEN",
|
|
393
|
+
"headers": {"Authorization": "Bearer configured-header", "X-Other": "keep"},
|
|
394
|
+
}
|
|
395
|
+
with mock.patch.dict(os.environ, {"SELECTED_MCP_TOKEN": " selected-token "}), mock.patch.object(
|
|
396
|
+
mcp, "_read_auth", side_effect=AssertionError("must not read stored OAuth")
|
|
397
|
+
), mock.patch.object(mcp, "host_request", new=mock.AsyncMock()) as request:
|
|
398
|
+
identity = run(mcp._auth_identity("remote", config))
|
|
399
|
+
self.assertNotEqual(identity, "anonymous")
|
|
400
|
+
self.assertNotIn("selected-token", identity)
|
|
401
|
+
self.assertEqual(run(mcp._headers("remote", config)), {
|
|
402
|
+
"Authorization": "Bearer selected-token", "X-Other": "keep",
|
|
403
|
+
})
|
|
404
|
+
request.assert_not_called()
|
|
405
|
+
|
|
320
406
|
def test_endpoint_bound_credential_never_attaches_to_another_url(self):
|
|
321
407
|
cred = {"access": "old-token", "endpoint": "https://old.example/mcp"}
|
|
322
408
|
config = {"oauth": True, "url": "https://new.example/mcp"}
|
package/dist/build-info.json
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
oauth_exports,
|
|
52
52
|
runPrintMode,
|
|
53
53
|
runRpcMode
|
|
54
|
-
} from "./chunk-
|
|
54
|
+
} from "./chunk-BV25CVRM.js";
|
|
55
55
|
import {
|
|
56
56
|
AgentSession,
|
|
57
57
|
CURRENT_SESSION_VERSION,
|
|
@@ -143,7 +143,7 @@ import {
|
|
|
143
143
|
withFileMutationQueue,
|
|
144
144
|
wrapRegisteredTool,
|
|
145
145
|
wrapRegisteredTools
|
|
146
|
-
} from "./chunk-
|
|
146
|
+
} from "./chunk-XDJ56NJA.js";
|
|
147
147
|
import "./chunk-CFNASUYO.js";
|
|
148
148
|
import "./chunk-HKINIBP4.js";
|
|
149
149
|
import {
|