strands-you 0.1.0__tar.gz
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.
- strands_you-0.1.0/.gitignore +9 -0
- strands_you-0.1.0/.python-version +1 -0
- strands_you-0.1.0/PKG-INFO +70 -0
- strands_you-0.1.0/README.md +46 -0
- strands_you-0.1.0/pyproject.toml +74 -0
- strands_you-0.1.0/scripts/stage.ts +35 -0
- strands_you-0.1.0/src/strands_you/__init__.py +7 -0
- strands_you-0.1.0/src/strands_you/plugin.py +117 -0
- strands_you-0.1.0/src/strands_you/py.typed +0 -0
- strands_you-0.1.0/tests/test_plugin.py +137 -0
- strands_you-0.1.0/uv.lock +1758 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: strands-you
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: You.com MCP servers and skills for Strands Agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/youdotcom-oss/agent-skills/tree/main/packages/strands-agents/python
|
|
6
|
+
Project-URL: Documentation, https://github.com/youdotcom-oss/agent-skills#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/youdotcom-oss/agent-skills
|
|
8
|
+
Project-URL: Issues, https://github.com/youdotcom-oss/agent-skills/issues
|
|
9
|
+
Author-email: "You.com" <oss@you.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: agents,ai,strands,tool
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: pydantic>=2.0.0
|
|
22
|
+
Requires-Dist: strands-agents>=1.0.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# strands-you
|
|
26
|
+
|
|
27
|
+
You.com MCP servers and Agent Skills for [Strands Agents](https://strandsagents.com), in one Python plugin.
|
|
28
|
+
|
|
29
|
+
`YouDotComPlugin` registers the You.com MCP servers (search, content extraction, cited research, finance data, docs lookup) and the bundled You.com Agent Skills, so a Strands agent gets web-grounded tools and the skills to use them well in one line.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pip install strands-you
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from strands import Agent
|
|
41
|
+
from strands_you import YouDotComPlugin
|
|
42
|
+
|
|
43
|
+
agent = Agent(plugins=[YouDotComPlugin()])
|
|
44
|
+
result = agent("What changed in the latest release of strands-agents? Cite sources.")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The API key comes from `YDC_API_KEY` by default, or pass it explicitly: `YouDotComPlugin(api_key="...")`. Get one at [you.com/platform](https://you.com/platform).
|
|
48
|
+
|
|
49
|
+
## What it configures
|
|
50
|
+
|
|
51
|
+
- MCP servers from `mcp.json`: `you`, `you-finance`, `you-research` (auth via
|
|
52
|
+
`YDC_API_KEY`), plus keyless `you-discover` and `you-docs`
|
|
53
|
+
- Skills from the bundled `skills/`: `you-web`, `you-research`, `you-finance`,
|
|
54
|
+
`you-discover`
|
|
55
|
+
|
|
56
|
+
Without an API key the keyed servers are skipped with a warning and the agent is told which servers are unavailable; keyless servers always work. The shipped server and skill sets mirror the repo-root `skills/` and `mcp.json` at release time.
|
|
57
|
+
|
|
58
|
+
## Build and test
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
bun scripts/stage.ts # stage shared skills + mcp.json (gitignored)
|
|
62
|
+
uv sync --group dev
|
|
63
|
+
uv run pytest -q
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The Python package shares its version line with the TypeScript package (`packages/strands-agents/typescript`); both are stamped by the release plan's `strands` unit.
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT. Bundled skills are from this repository, MIT.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# strands-you
|
|
2
|
+
|
|
3
|
+
You.com MCP servers and Agent Skills for [Strands Agents](https://strandsagents.com), in one Python plugin.
|
|
4
|
+
|
|
5
|
+
`YouDotComPlugin` registers the You.com MCP servers (search, content extraction, cited research, finance data, docs lookup) and the bundled You.com Agent Skills, so a Strands agent gets web-grounded tools and the skills to use them well in one line.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install strands-you
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from strands import Agent
|
|
17
|
+
from strands_you import YouDotComPlugin
|
|
18
|
+
|
|
19
|
+
agent = Agent(plugins=[YouDotComPlugin()])
|
|
20
|
+
result = agent("What changed in the latest release of strands-agents? Cite sources.")
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The API key comes from `YDC_API_KEY` by default, or pass it explicitly: `YouDotComPlugin(api_key="...")`. Get one at [you.com/platform](https://you.com/platform).
|
|
24
|
+
|
|
25
|
+
## What it configures
|
|
26
|
+
|
|
27
|
+
- MCP servers from `mcp.json`: `you`, `you-finance`, `you-research` (auth via
|
|
28
|
+
`YDC_API_KEY`), plus keyless `you-discover` and `you-docs`
|
|
29
|
+
- Skills from the bundled `skills/`: `you-web`, `you-research`, `you-finance`,
|
|
30
|
+
`you-discover`
|
|
31
|
+
|
|
32
|
+
Without an API key the keyed servers are skipped with a warning and the agent is told which servers are unavailable; keyless servers always work. The shipped server and skill sets mirror the repo-root `skills/` and `mcp.json` at release time.
|
|
33
|
+
|
|
34
|
+
## Build and test
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
bun scripts/stage.ts # stage shared skills + mcp.json (gitignored)
|
|
38
|
+
uv sync --group dev
|
|
39
|
+
uv run pytest -q
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The Python package shares its version line with the TypeScript package (`packages/strands-agents/typescript`); both are stamped by the release plan's `strands` unit.
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
MIT. Bundled skills are from this repository, MIT.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "strands-you"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "You.com MCP servers and skills for Strands Agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "You.com", email = "oss@you.com"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["strands", "agents", "ai", "tool"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
dependencies = [
|
|
28
|
+
"strands-agents>=1.0.0",
|
|
29
|
+
"pydantic>=2.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/youdotcom-oss/agent-skills/tree/main/packages/strands-agents/python"
|
|
34
|
+
Documentation = "https://github.com/youdotcom-oss/agent-skills#readme"
|
|
35
|
+
Repository = "https://github.com/youdotcom-oss/agent-skills"
|
|
36
|
+
Issues = "https://github.com/youdotcom-oss/agent-skills/issues"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=8.0.0,<9.0.0",
|
|
41
|
+
"pytest-asyncio>=0.25.0,<1.0.0",
|
|
42
|
+
"ruff>=0.11.0,<1.0.0",
|
|
43
|
+
"mypy>=1.15.0,<2.0.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Environment management is uv: `uv sync` creates .venv from uv.lock, and
|
|
47
|
+
# everything runs through `uv run` (see CI). Hatchling remains the build
|
|
48
|
+
# backend — `uv build` drives it for release artifacts.
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/strands_you"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 120
|
|
55
|
+
include = ["src/**/*.py", "tests/**/*.py"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = [
|
|
59
|
+
"E", # pycodestyle
|
|
60
|
+
"F", # pyflakes
|
|
61
|
+
"I", # isort
|
|
62
|
+
"B", # flake8-bugbear
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.mypy]
|
|
66
|
+
python_version = "3.10"
|
|
67
|
+
warn_return_any = true
|
|
68
|
+
warn_unused_configs = true
|
|
69
|
+
ignore_missing_imports = true
|
|
70
|
+
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
testpaths = ["tests"]
|
|
73
|
+
pythonpath = ["src"]
|
|
74
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage the shared You.com assets from the repo root into the Python package
|
|
3
|
+
* (gitignored build inputs): repo-root skills/ + mcp.json ->
|
|
4
|
+
* src/strands_you/{skills/,mcp.json}. Run before `uv build` and
|
|
5
|
+
* before tests — the plugin loads the shipped assets via importlib.resources,
|
|
6
|
+
* so tests exercise exactly what the wheel will contain.
|
|
7
|
+
*
|
|
8
|
+
* MINIMAL: staging logic duplicated per package instead of a shared root
|
|
9
|
+
* helper — the house pattern is self-contained package builds. Upgrade path:
|
|
10
|
+
* extract a shared scripts/copy-skills.ts when a sixth consumer appears.
|
|
11
|
+
*/
|
|
12
|
+
import { cpSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
13
|
+
|
|
14
|
+
const repoRoot = new URL('../../../../', import.meta.url)
|
|
15
|
+
const packageAssetsDir = new URL('../src/strands_you/', import.meta.url)
|
|
16
|
+
|
|
17
|
+
const isDirectory = (path: URL) => statSync(path, { throwIfNoEntry: false })?.isDirectory()
|
|
18
|
+
|
|
19
|
+
if (!isDirectory(new URL('skills/', repoRoot))) throw new Error(`Missing source skills directory: ${repoRoot}skills/`)
|
|
20
|
+
|
|
21
|
+
rmSync(new URL('skills/', packageAssetsDir), { recursive: true, force: true })
|
|
22
|
+
rmSync(new URL('mcp.json', packageAssetsDir), { force: true })
|
|
23
|
+
|
|
24
|
+
let staged = 0
|
|
25
|
+
for (const entry of readdirSync(new URL('skills/', repoRoot))) {
|
|
26
|
+
const sourceSkillDir = new URL(`skills/${entry}/`, repoRoot)
|
|
27
|
+
if (isDirectory(sourceSkillDir) && statSync(new URL('SKILL.md', sourceSkillDir), { throwIfNoEntry: false })) {
|
|
28
|
+
cpSync(sourceSkillDir, new URL(`skills/${entry}/`, packageAssetsDir), { recursive: true })
|
|
29
|
+
staged += 1
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (staged === 0) throw new Error(`No skills found in ${repoRoot}skills/`)
|
|
33
|
+
cpSync(new URL('mcp.json', repoRoot), new URL('mcp.json', packageAssetsDir), { force: true })
|
|
34
|
+
|
|
35
|
+
console.log(`staged ${staged} skills + mcp.json -> src/strands_you/`)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""You.com plugin for Strands Agents.
|
|
2
|
+
|
|
3
|
+
Bundles the You.com MCP servers (from the shipped ``mcp.json``) and the You.com
|
|
4
|
+
Agent Skills (from the bundled ``skills/`` directory) into a single plugin:
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from strands import Agent
|
|
8
|
+
from strands_you import YouDotComPlugin
|
|
9
|
+
|
|
10
|
+
agent = Agent(plugins=[YouDotComPlugin()])
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Servers that require an API key are enabled only when ``YDC_API_KEY`` (or the
|
|
14
|
+
explicit ``api_key`` argument) is available; keyless servers are always enabled.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import warnings
|
|
21
|
+
from importlib import resources
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from strands.hooks import BeforeInvocationEvent
|
|
25
|
+
from strands.plugins import hook
|
|
26
|
+
from strands.tools.mcp import MCPClient
|
|
27
|
+
from strands.vended_plugins.skills import AgentSkills
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
#: Servers that authenticate with ``YDC_API_KEY``. Any server in the shipped
|
|
32
|
+
#: ``mcp.json`` not listed here is treated as keyless. Keying this policy by
|
|
33
|
+
#: server name keeps the shipped ``mcp.json`` the single source of truth for
|
|
34
|
+
#: server topology (urls) while auth policy stays a consumer-side concern.
|
|
35
|
+
_API_KEY_SERVERS = frozenset({"you", "you-finance", "you-research"})
|
|
36
|
+
|
|
37
|
+
_API_KEY_ENV_VAR = "YDC_API_KEY"
|
|
38
|
+
|
|
39
|
+
_CAPABILITY_NOTE_MARKER = "unavailable You.com MCP servers"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_mcp_config() -> dict[str, dict[str, Any]]:
|
|
43
|
+
"""Load the shipped ``mcp.json`` and return its server entries by name."""
|
|
44
|
+
mcp_config: Any = json.loads(resources.files("strands_you").joinpath("mcp.json").read_text(encoding="utf-8"))
|
|
45
|
+
servers: dict[str, dict[str, Any]] = mcp_config.get("mcpServers", {})
|
|
46
|
+
return servers
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class YouDotComPlugin(AgentSkills):
|
|
50
|
+
"""Strands plugin bundling You.com MCP servers and Agent Skills.
|
|
51
|
+
|
|
52
|
+
Subclasses the SDK's ``AgentSkills`` plugin so the bundled skills are
|
|
53
|
+
discovered and activated by the agent, and registers one MCP client per
|
|
54
|
+
enabled server when attached to an agent.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
api_key: You.com API key. Defaults to the ``YDC_API_KEY`` environment
|
|
58
|
+
variable. When absent, servers that require a key are skipped with
|
|
59
|
+
a warning and only keyless servers are enabled.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
name = "youdotcom"
|
|
63
|
+
|
|
64
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
65
|
+
"""Initialize the plugin from the shipped ``mcp.json``."""
|
|
66
|
+
self._api_key = api_key if api_key is not None else os.environ.get(_API_KEY_ENV_VAR)
|
|
67
|
+
self._all_servers = _load_mcp_config()
|
|
68
|
+
self._servers = {name: server for name, server in self._all_servers.items() if self._server_enabled(name)}
|
|
69
|
+
self._skipped_servers = sorted(set(self._all_servers) - set(self._servers))
|
|
70
|
+
super().__init__(skills=[str(resources.files("strands_you").joinpath("skills"))])
|
|
71
|
+
|
|
72
|
+
def _server_enabled(self, name: str) -> bool:
|
|
73
|
+
"""Return True when the named server is usable with the current auth."""
|
|
74
|
+
if name not in _API_KEY_SERVERS:
|
|
75
|
+
return True
|
|
76
|
+
if self._api_key:
|
|
77
|
+
return True
|
|
78
|
+
warnings.warn(
|
|
79
|
+
f"Server {name!r} requires an API key; set YDC_API_KEY to enable it. Enabled keyless servers only.",
|
|
80
|
+
stacklevel=2,
|
|
81
|
+
)
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def servers(self) -> dict[str, dict[str, Any]]:
|
|
86
|
+
"""Enabled MCP server configs by name, as shipped in ``mcp.json``."""
|
|
87
|
+
return dict(self._servers)
|
|
88
|
+
|
|
89
|
+
def init_agent(self, agent: Any) -> None: # type: ignore[override] # registry supports sync init
|
|
90
|
+
"""Register one MCP client per enabled server with the agent.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
agent: The agent instance the plugin is attached to.
|
|
94
|
+
"""
|
|
95
|
+
for name, server in self._servers.items():
|
|
96
|
+
headers = {"Authorization": f"Bearer {self._api_key}"} if name in _API_KEY_SERVERS else None
|
|
97
|
+
client = MCPClient(url=server["url"], headers=headers)
|
|
98
|
+
agent.tool_registry.process_tools([client])
|
|
99
|
+
logger.debug("server=<%s> | registered MCP client", name)
|
|
100
|
+
|
|
101
|
+
@hook # type: ignore[call-overload] # same as the SDK's own plugin template
|
|
102
|
+
async def _on_before_invocation_note(self, event: BeforeInvocationEvent) -> None:
|
|
103
|
+
"""Tell the agent which servers are unavailable when no API key is set.
|
|
104
|
+
|
|
105
|
+
Without this, an agent running keyless discovers its own capability
|
|
106
|
+
boundary only by failing tool calls. Runs on every invocation; the note
|
|
107
|
+
is appended once (idempotent via a stable marker).
|
|
108
|
+
"""
|
|
109
|
+
if self._api_key or not self._skipped_servers:
|
|
110
|
+
return
|
|
111
|
+
note = (
|
|
112
|
+
f"The following You.com MCP servers are {_CAPABILITY_NOTE_MARKER} "
|
|
113
|
+
f"because no API key is configured: {', '.join(self._skipped_servers)}. "
|
|
114
|
+
f"Set {_API_KEY_ENV_VAR} to enable them."
|
|
115
|
+
)
|
|
116
|
+
if hasattr(event.agent, "system_prompt") and _CAPABILITY_NOTE_MARKER not in (event.agent.system_prompt or ""):
|
|
117
|
+
event.agent.system_prompt = (event.agent.system_prompt or "") + "\n\n" + note
|
|
File without changes
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Tests for YouDotComPlugin."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from strands import Agent
|
|
7
|
+
from strands.hooks import BeforeInvocationEvent
|
|
8
|
+
|
|
9
|
+
import strands_you.plugin as plugin_mod
|
|
10
|
+
from strands_you import YouDotComPlugin
|
|
11
|
+
|
|
12
|
+
#: The server set comes from the shipped mcp.json mirror, which the release
|
|
13
|
+
#: sync owns. Expectations are derived from it so a sync that adds or removes
|
|
14
|
+
#: a server never breaks these tests — the auth policy, not the topology, is
|
|
15
|
+
#: what they pin down.
|
|
16
|
+
ALL_SERVERS = set(plugin_mod._load_mcp_config())
|
|
17
|
+
KEYED_SERVERS = set(plugin_mod._API_KEY_SERVERS)
|
|
18
|
+
KEYLESS_SERVERS = ALL_SERVERS - KEYED_SERVERS
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _InertProvider:
|
|
22
|
+
"""Stands in for a real MCPClient so tests never touch the network."""
|
|
23
|
+
|
|
24
|
+
async def load_tools(self, **kwargs):
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
def add_consumer(self, registry_id):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_plugin_exposes_stable_name():
|
|
32
|
+
"""The plugin exposes its stable name."""
|
|
33
|
+
plugin = YouDotComPlugin()
|
|
34
|
+
assert plugin.name == "youdotcom"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_without_api_key_only_keyless_servers_are_enabled(monkeypatch: pytest.MonkeyPatch):
|
|
38
|
+
"""Without YDC_API_KEY, keyed servers are skipped and keyless ones remain."""
|
|
39
|
+
monkeypatch.delenv("YDC_API_KEY", raising=False)
|
|
40
|
+
plugin = YouDotComPlugin()
|
|
41
|
+
assert set(plugin.servers) == KEYLESS_SERVERS
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_with_api_key_all_servers_are_enabled(monkeypatch: pytest.MonkeyPatch):
|
|
45
|
+
"""With YDC_API_KEY (env or explicit), every shipped server is enabled."""
|
|
46
|
+
monkeypatch.setenv("YDC_API_KEY", "test-key")
|
|
47
|
+
from_env = YouDotComPlugin()
|
|
48
|
+
explicit = YouDotComPlugin(api_key="explicit-key")
|
|
49
|
+
assert set(from_env.servers) == ALL_SERVERS
|
|
50
|
+
assert set(explicit.servers) == ALL_SERVERS
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _FakeAgent:
|
|
54
|
+
"""Captures tool providers the way the plugin registry would register them."""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self.providers: list[object] = []
|
|
58
|
+
|
|
59
|
+
class _Registry:
|
|
60
|
+
def __init__(self, outer: "_FakeAgent") -> None:
|
|
61
|
+
self._outer = outer
|
|
62
|
+
|
|
63
|
+
def process_tools(self, tools: list[object]) -> list[str]:
|
|
64
|
+
self._outer.providers.extend(tools)
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
self.tool_registry = _Registry(self)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_init_agent_registers_one_client_per_enabled_server(monkeypatch: pytest.MonkeyPatch):
|
|
71
|
+
"""init_agent hands one MCP client per enabled server to the tool registry,
|
|
72
|
+
with a Bearer header on keyed servers and no header on keyless ones."""
|
|
73
|
+
client_calls: list[dict] = []
|
|
74
|
+
|
|
75
|
+
def fake_mcp_client(_factory=None, *, url=None, headers=None, **_kwargs):
|
|
76
|
+
client_calls.append({"url": url, "headers": headers})
|
|
77
|
+
return object()
|
|
78
|
+
|
|
79
|
+
monkeypatch.setattr(plugin_mod, "MCPClient", fake_mcp_client)
|
|
80
|
+
|
|
81
|
+
plugin = YouDotComPlugin(api_key="test-key")
|
|
82
|
+
fake_agent = _FakeAgent()
|
|
83
|
+
plugin.init_agent(fake_agent) # type: ignore[arg-type]
|
|
84
|
+
|
|
85
|
+
assert len(fake_agent.providers) == len(ALL_SERVERS)
|
|
86
|
+
by_url = {call["url"]: call["headers"] for call in client_calls}
|
|
87
|
+
assert len(client_calls) == len(ALL_SERVERS)
|
|
88
|
+
for name, server in plugin.servers.items():
|
|
89
|
+
if name in KEYED_SERVERS:
|
|
90
|
+
assert by_url[server["url"]] == {"Authorization": "Bearer test-key"}
|
|
91
|
+
else:
|
|
92
|
+
assert by_url[server["url"]] is None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_attached_agent_discovers_bundled_skills_and_skills_tool(monkeypatch: pytest.MonkeyPatch):
|
|
96
|
+
"""An agent with the plugin gains the skills tool and sees the bundled skills."""
|
|
97
|
+
monkeypatch.setattr(plugin_mod, "MCPClient", lambda *args, **kwargs: _InertProvider())
|
|
98
|
+
|
|
99
|
+
agent = Agent(plugins=[YouDotComPlugin()])
|
|
100
|
+
|
|
101
|
+
assert "skills" in agent.tool_registry.get_all_tools_config()
|
|
102
|
+
attached = agent._plugin_registry._plugins["youdotcom"]
|
|
103
|
+
# Skill discovery is lazy: the same load runs at each invocation via the
|
|
104
|
+
# BeforeInvocationEvent hook. Exercise that exact path here.
|
|
105
|
+
asyncio.run(attached._on_before_invocation(BeforeInvocationEvent(agent=agent)))
|
|
106
|
+
names = {skill.name for skill in attached.get_available_skills(agent)}
|
|
107
|
+
assert names == {"you-web", "you-research", "you-discover", "you-finance"}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_without_key_agent_system_prompt_notes_skipped_servers(monkeypatch: pytest.MonkeyPatch):
|
|
111
|
+
"""Without a key the agent's system prompt says which servers are unavailable."""
|
|
112
|
+
monkeypatch.delenv("YDC_API_KEY", raising=False)
|
|
113
|
+
monkeypatch.setattr(plugin_mod, "MCPClient", lambda *args, **kwargs: _InertProvider())
|
|
114
|
+
|
|
115
|
+
agent = Agent(plugins=[YouDotComPlugin()])
|
|
116
|
+
attached = agent._plugin_registry._plugins["youdotcom"]
|
|
117
|
+
# The registry runs every callback registered for the event; replicate that
|
|
118
|
+
# ordering here: the inherited skills hook, then the capability-note hook.
|
|
119
|
+
asyncio.run(attached._on_before_invocation(BeforeInvocationEvent(agent=agent)))
|
|
120
|
+
asyncio.run(attached._on_before_invocation_note(BeforeInvocationEvent(agent=agent)))
|
|
121
|
+
|
|
122
|
+
assert "YDC_API_KEY" in agent.system_prompt
|
|
123
|
+
for name in KEYED_SERVERS: # servers the agent can NOT call
|
|
124
|
+
assert name in agent.system_prompt
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_with_key_agent_system_prompt_has_no_capability_note(monkeypatch: pytest.MonkeyPatch):
|
|
128
|
+
"""With a key present, no capability note is injected."""
|
|
129
|
+
monkeypatch.setenv("YDC_API_KEY", "test-key")
|
|
130
|
+
monkeypatch.setattr(plugin_mod, "MCPClient", lambda *args, **kwargs: _InertProvider())
|
|
131
|
+
|
|
132
|
+
agent = Agent(plugins=[YouDotComPlugin()])
|
|
133
|
+
attached = agent._plugin_registry._plugins["youdotcom"]
|
|
134
|
+
asyncio.run(attached._on_before_invocation(BeforeInvocationEvent(agent=agent)))
|
|
135
|
+
asyncio.run(attached._on_before_invocation_note(BeforeInvocationEvent(agent=agent)))
|
|
136
|
+
|
|
137
|
+
assert "YDC_API_KEY" not in agent.system_prompt
|