agent-as-model 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fernando Garza
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-as-model
3
+ Version: 0.1.0
4
+ Summary: A true MCP server where the agent is the model and the API key.
5
+ Author: Fernando Garza
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/fernandogarzaaa/agent-as-model
8
+ Project-URL: Repository, https://github.com/fernandogarzaaa/agent-as-model
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: mcp<3,>=2
13
+ Dynamic: license-file
14
+
15
+ # agent-as-model
16
+
17
+ A true MCP server where the agent **is** the model and the API key.
18
+
19
+ Install it on any MCP host (Claude Code, Cursor, Windsurf, any spec-compliant
20
+ client). It exposes product-style completion tools. Every call is fulfilled by
21
+ sampling the host agent's own LLM. No API keys exist anywhere in this system.
22
+
23
+ ## The idea
24
+
25
+ AI products usually demand a model API key before they do anything useful.
26
+ This server flips that around. The tools speak the request shapes products
27
+ already use, and the intelligence comes from the agent on the other end of
28
+ the MCP connection. The agent is the model. The agent is the key.
29
+
30
+ ## Install
31
+
32
+ Requires Python 3.10+ and a host that declares the MCP `sampling`
33
+ capability. No API keys, ever.
34
+
35
+ Run it straight from the repo with uvx (no install step):
36
+
37
+ ```bash
38
+ claude mcp add agent-as-model -- uvx --from git+https://github.com/fernandogarzaaa/agent-as-model agent-as-model
39
+ ```
40
+
41
+ Or install it for real:
42
+
43
+ ```bash
44
+ pipx install git+https://github.com/fernandogarzaaa/agent-as-model
45
+ # then
46
+ claude mcp add agent-as-model -- agent-as-model
47
+ ```
48
+
49
+ Cursor, Windsurf, or any client that takes MCP JSON:
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "agent-as-model": {
55
+ "command": "uvx",
56
+ "args": ["--from", "git+https://github.com/fernandogarzaaa/agent-as-model", "agent-as-model"]
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ From a local checkout: `python -m agent_as_model`.
63
+
64
+ ## Tools
65
+
66
+ **chat_complete** — OpenAI chat-completions-shaped request, completion text back.
67
+
68
+ ```json
69
+ {
70
+ "messages": [
71
+ {"role": "system", "content": "You are terse."},
72
+ {"role": "user", "content": "Explain MCP sampling."}
73
+ ],
74
+ "model": "agent-default",
75
+ "temperature": 0.7,
76
+ "max_tokens": 500
77
+ }
78
+ ```
79
+
80
+ **messages_create** — Anthropic messages-shaped request, completion text back.
81
+ `system` accepts a string or a list of text blocks. Message content accepts a
82
+ string or a list of text blocks.
83
+
84
+ **list_models** — returns the one model this server offers: `agent-default`,
85
+ an alias for the host agent's own model.
86
+
87
+ ## How it works
88
+
89
+ 1. A product (or agent) calls `chat_complete` with ordinary chat messages.
90
+ 2. The server translates them into MCP sampling messages and asks the host
91
+ to complete them. On pre-2026 connections this is a direct
92
+ `sampling/createMessage` request. On 2026-07-28+ it rides the
93
+ multi-round-trip pattern. One tool body serves both eras.
94
+ 3. The host agent generates the completion with its own model and the server
95
+ returns the text. The `model` argument is passed to the host as a model
96
+ hint only; `agent-default` means "your own model".
97
+
98
+ ## Honest caveats
99
+
100
+ - Sampling is deprecated upstream (SEP-2577, spec 2026-07-28, earliest
101
+ removal 2027-07-28). The official migration path is provider API keys.
102
+ This project exists because the agent-as-model architecture is the point,
103
+ not the keys. The era-portable form used here is the sanctioned way to
104
+ keep it working across both spec eras.
105
+ - The host is in charge. Sampling runs under the host's consent and policy.
106
+ A host without the sampling capability gets a clear
107
+ `MISSING_REQUIRED_CLIENT_CAPABILITY` error, not a silent failure.
108
+ - Latency is one host model call per tool call. This is a thinking
109
+ endpoint, not a fast one.
110
+ - v1 serves text only. Tool calls, images, and audio in either direction
111
+ are rejected with explicit errors, not silently dropped.
112
+
113
+ ## Roadmap
114
+
115
+ - Provider-key fallback for hosts without sampling (opt-in, per-call choice
116
+ stays with the user).
117
+ - Structured output passthrough where hosts support it.
118
+ - More product shapes as tools (embeddings-shaped requests cannot be
119
+ sampled meaningfully; out of scope by design).
120
+
121
+ ## Development
122
+
123
+ ```bash
124
+ python3 -m pytest tests/ -q
125
+ python3 -m ruff format . && python3 -m ruff check .
126
+ ```
127
+
128
+ 14 tests, all green: message translation unit tests plus end-to-end runs of
129
+ the full MCP loop (tool call to sampled completion) against a scripted host
130
+ on both the modern multi-round-trip era and the legacy direct-sampling era.
@@ -0,0 +1,116 @@
1
+ # agent-as-model
2
+
3
+ A true MCP server where the agent **is** the model and the API key.
4
+
5
+ Install it on any MCP host (Claude Code, Cursor, Windsurf, any spec-compliant
6
+ client). It exposes product-style completion tools. Every call is fulfilled by
7
+ sampling the host agent's own LLM. No API keys exist anywhere in this system.
8
+
9
+ ## The idea
10
+
11
+ AI products usually demand a model API key before they do anything useful.
12
+ This server flips that around. The tools speak the request shapes products
13
+ already use, and the intelligence comes from the agent on the other end of
14
+ the MCP connection. The agent is the model. The agent is the key.
15
+
16
+ ## Install
17
+
18
+ Requires Python 3.10+ and a host that declares the MCP `sampling`
19
+ capability. No API keys, ever.
20
+
21
+ Run it straight from the repo with uvx (no install step):
22
+
23
+ ```bash
24
+ claude mcp add agent-as-model -- uvx --from git+https://github.com/fernandogarzaaa/agent-as-model agent-as-model
25
+ ```
26
+
27
+ Or install it for real:
28
+
29
+ ```bash
30
+ pipx install git+https://github.com/fernandogarzaaa/agent-as-model
31
+ # then
32
+ claude mcp add agent-as-model -- agent-as-model
33
+ ```
34
+
35
+ Cursor, Windsurf, or any client that takes MCP JSON:
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "agent-as-model": {
41
+ "command": "uvx",
42
+ "args": ["--from", "git+https://github.com/fernandogarzaaa/agent-as-model", "agent-as-model"]
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ From a local checkout: `python -m agent_as_model`.
49
+
50
+ ## Tools
51
+
52
+ **chat_complete** — OpenAI chat-completions-shaped request, completion text back.
53
+
54
+ ```json
55
+ {
56
+ "messages": [
57
+ {"role": "system", "content": "You are terse."},
58
+ {"role": "user", "content": "Explain MCP sampling."}
59
+ ],
60
+ "model": "agent-default",
61
+ "temperature": 0.7,
62
+ "max_tokens": 500
63
+ }
64
+ ```
65
+
66
+ **messages_create** — Anthropic messages-shaped request, completion text back.
67
+ `system` accepts a string or a list of text blocks. Message content accepts a
68
+ string or a list of text blocks.
69
+
70
+ **list_models** — returns the one model this server offers: `agent-default`,
71
+ an alias for the host agent's own model.
72
+
73
+ ## How it works
74
+
75
+ 1. A product (or agent) calls `chat_complete` with ordinary chat messages.
76
+ 2. The server translates them into MCP sampling messages and asks the host
77
+ to complete them. On pre-2026 connections this is a direct
78
+ `sampling/createMessage` request. On 2026-07-28+ it rides the
79
+ multi-round-trip pattern. One tool body serves both eras.
80
+ 3. The host agent generates the completion with its own model and the server
81
+ returns the text. The `model` argument is passed to the host as a model
82
+ hint only; `agent-default` means "your own model".
83
+
84
+ ## Honest caveats
85
+
86
+ - Sampling is deprecated upstream (SEP-2577, spec 2026-07-28, earliest
87
+ removal 2027-07-28). The official migration path is provider API keys.
88
+ This project exists because the agent-as-model architecture is the point,
89
+ not the keys. The era-portable form used here is the sanctioned way to
90
+ keep it working across both spec eras.
91
+ - The host is in charge. Sampling runs under the host's consent and policy.
92
+ A host without the sampling capability gets a clear
93
+ `MISSING_REQUIRED_CLIENT_CAPABILITY` error, not a silent failure.
94
+ - Latency is one host model call per tool call. This is a thinking
95
+ endpoint, not a fast one.
96
+ - v1 serves text only. Tool calls, images, and audio in either direction
97
+ are rejected with explicit errors, not silently dropped.
98
+
99
+ ## Roadmap
100
+
101
+ - Provider-key fallback for hosts without sampling (opt-in, per-call choice
102
+ stays with the user).
103
+ - Structured output passthrough where hosts support it.
104
+ - More product shapes as tools (embeddings-shaped requests cannot be
105
+ sampled meaningfully; out of scope by design).
106
+
107
+ ## Development
108
+
109
+ ```bash
110
+ python3 -m pytest tests/ -q
111
+ python3 -m ruff format . && python3 -m ruff check .
112
+ ```
113
+
114
+ 14 tests, all green: message translation unit tests plus end-to-end runs of
115
+ the full MCP loop (tool call to sampled completion) against a scripted host
116
+ on both the modern multi-round-trip era and the legacy direct-sampling era.
@@ -0,0 +1,3 @@
1
+ """agent-as-model: a true MCP server where the agent is the model and the API key."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Run the agent-as-model MCP server: python -m agent_as_model."""
2
+
3
+ from agent_as_model.server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,95 @@
1
+ """Message translation for agent-as-model.
2
+
3
+ The server never touches a model API key. Every completion is produced by the
4
+ host agent's own LLM through MCP sampling (or the multi-round-trip successor
5
+ on 2026-07-28+ connections). This module translates the two most common
6
+ product API shapes into MCP sampling messages and extracts plain text from
7
+ sampling results.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Literal
13
+
14
+ from mcp.types import CreateMessageResult, SamplingMessage, TextContent
15
+ from pydantic import BaseModel
16
+
17
+
18
+ class ChatMessage(BaseModel):
19
+ """One OpenAI chat-completions message (text content only, v1)."""
20
+
21
+ role: Literal["user", "assistant", "system"]
22
+ content: str
23
+
24
+
25
+ class AnthropicContentBlock(BaseModel):
26
+ """One Anthropic text content block (v1 supports text only)."""
27
+
28
+ type: Literal["text"]
29
+ text: str
30
+
31
+
32
+ class AnthropicMessage(BaseModel):
33
+ """One Anthropic messages API message (text only, v1)."""
34
+
35
+ role: Literal["user", "assistant"]
36
+ content: str | list[AnthropicContentBlock]
37
+
38
+
39
+ def _text_of(content: str | list[AnthropicContentBlock]) -> str:
40
+ if isinstance(content, str):
41
+ return content
42
+ return "\n".join(block.text for block in content)
43
+
44
+
45
+ def openai_to_sampling(
46
+ messages: list[ChatMessage],
47
+ ) -> tuple[str | None, list[SamplingMessage]]:
48
+ """Split OpenAI chat messages into a system prompt and sampling messages."""
49
+ if not messages:
50
+ raise ValueError("messages must be a non-empty list")
51
+ system_parts: list[str] = []
52
+ sampling: list[SamplingMessage] = []
53
+ for message in messages:
54
+ if message.role == "system":
55
+ system_parts.append(message.content)
56
+ else:
57
+ sampling.append(
58
+ SamplingMessage(
59
+ role=message.role,
60
+ content=TextContent(type="text", text=message.content),
61
+ )
62
+ )
63
+ system_prompt = "\n\n".join(system_parts) if system_parts else None
64
+ return system_prompt, sampling
65
+
66
+
67
+ def anthropic_to_sampling(
68
+ messages: list[AnthropicMessage],
69
+ system: str | list[AnthropicContentBlock] | None,
70
+ ) -> tuple[str | None, list[SamplingMessage]]:
71
+ """Split an Anthropic messages request into a system prompt and sampling messages."""
72
+ if not messages:
73
+ raise ValueError("messages must be a non-empty list")
74
+ system_prompt = _text_of(system) if system is not None else None
75
+ sampling = [
76
+ SamplingMessage(
77
+ role=message.role,
78
+ content=TextContent(type="text", text=_text_of(message.content)),
79
+ )
80
+ for message in messages
81
+ ]
82
+ return system_prompt, sampling
83
+
84
+
85
+ def extract_text(result: CreateMessageResult) -> str:
86
+ """Pull plain text out of a sampling result.
87
+
88
+ Raises ValueError on non-text content instead of silently stringifying it.
89
+ """
90
+ content = result.content
91
+ if isinstance(content, TextContent):
92
+ return content.text
93
+ raise ValueError(
94
+ f"agent-as-model serves text completions only, the host returned {type(content).__name__}"
95
+ )
@@ -0,0 +1,162 @@
1
+ """agent-as-model: a true MCP server where the agent IS the model and the API key.
2
+
3
+ Install on any MCP host (Claude Code, Cursor, Windsurf, ...). The server
4
+ exposes product-style completion tools; every call is fulfilled by sampling
5
+ the host agent's own LLM. No API keys exist anywhere in this system.
6
+
7
+ Tools:
8
+ chat_complete OpenAI chat-completions-shaped request -> completion text
9
+ messages_create Anthropic messages-shaped request -> completion text
10
+ list_models the model this server offers (the host's own model, aliased)
11
+
12
+ Upstream note: sampling is deprecated as of the 2026-07-28 spec (SEP-2577,
13
+ earliest removal 2027-07-28). This server uses the era-portable
14
+ Resolve/Sample form: a direct sampling request on pre-2026 connections and
15
+ the multi-round-trip pattern on 2026-07-28+. One tool body serves both eras.
16
+ The host must declare the sampling capability or calls fail with a clear
17
+ MISSING_REQUIRED_CLIENT_CAPABILITY error.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from typing import Annotated
24
+
25
+ from mcp.server.mcpserver import MCPServer, Resolve, Sample
26
+ from mcp.types import (
27
+ CreateMessageResult,
28
+ ModelHint,
29
+ ModelPreferences,
30
+ )
31
+ from pydantic import Field
32
+
33
+ from agent_as_model import __version__
34
+ from agent_as_model.protocol import (
35
+ AnthropicContentBlock,
36
+ AnthropicMessage,
37
+ ChatMessage,
38
+ anthropic_to_sampling,
39
+ extract_text,
40
+ openai_to_sampling,
41
+ )
42
+
43
+ mcp = MCPServer(
44
+ name="agent-as-model",
45
+ version=__version__,
46
+ instructions=(
47
+ "Completion tools backed by the host agent's own model. "
48
+ "No API keys are used or needed: the connected agent is the model."
49
+ ),
50
+ )
51
+
52
+
53
+ async def _sample_chat(
54
+ messages: list[ChatMessage],
55
+ model: str,
56
+ temperature: float | None,
57
+ max_tokens: int,
58
+ ) -> Sample:
59
+ system_prompt, sampling_messages = openai_to_sampling(messages)
60
+ return Sample(
61
+ messages=sampling_messages,
62
+ system_prompt=system_prompt,
63
+ max_tokens=max_tokens,
64
+ temperature=temperature,
65
+ model_preferences=ModelPreferences(hints=[ModelHint(name=model)]),
66
+ )
67
+
68
+
69
+ @mcp.tool(
70
+ description=(
71
+ "Generate a chat completion from an OpenAI chat-completions-shaped request. "
72
+ "The completion is produced by the host agent's own model via MCP sampling. "
73
+ "No API key is used or needed: the connected agent is the model."
74
+ )
75
+ )
76
+ async def chat_complete(
77
+ completion: Annotated[CreateMessageResult, Resolve(_sample_chat)],
78
+ messages: Annotated[
79
+ list[ChatMessage],
80
+ Field(description="Chat messages; system roles become the system prompt."),
81
+ ],
82
+ model: Annotated[
83
+ str, Field(description="Model alias; agent-default means the host's own model.")
84
+ ] = "agent-default",
85
+ temperature: Annotated[
86
+ float | None, Field(description="Sampling temperature, if the host honors it.")
87
+ ] = None,
88
+ max_tokens: Annotated[int, Field(description="Maximum tokens to generate.")] = 4096,
89
+ ) -> str:
90
+ """Complete a chat with the host agent's model (OpenAI request shape)."""
91
+ return extract_text(completion)
92
+
93
+
94
+ async def _sample_messages(
95
+ messages: list[AnthropicMessage],
96
+ system: str | list[AnthropicContentBlock] | None,
97
+ model: str,
98
+ temperature: float | None,
99
+ max_tokens: int,
100
+ ) -> Sample:
101
+ system_prompt, sampling_messages = anthropic_to_sampling(messages, system)
102
+ return Sample(
103
+ messages=sampling_messages,
104
+ system_prompt=system_prompt,
105
+ max_tokens=max_tokens,
106
+ temperature=temperature,
107
+ model_preferences=ModelPreferences(hints=[ModelHint(name=model)]),
108
+ )
109
+
110
+
111
+ @mcp.tool(
112
+ description=(
113
+ "Generate a completion from an Anthropic messages-shaped request. "
114
+ "The completion is produced by the host agent's own model via MCP sampling. "
115
+ "No API key is used or needed: the connected agent is the model."
116
+ )
117
+ )
118
+ async def messages_create(
119
+ completion: Annotated[CreateMessageResult, Resolve(_sample_messages)],
120
+ messages: Annotated[
121
+ list[AnthropicMessage], Field(description="User/assistant messages in order.")
122
+ ],
123
+ model: Annotated[
124
+ str, Field(description="Model alias; agent-default means the host's own model.")
125
+ ] = "agent-default",
126
+ system: Annotated[
127
+ str | list[AnthropicContentBlock] | None,
128
+ Field(description="System prompt as text or text blocks."),
129
+ ] = None,
130
+ temperature: Annotated[
131
+ float | None, Field(description="Sampling temperature, if the host honors it.")
132
+ ] = None,
133
+ max_tokens: Annotated[int, Field(description="Maximum tokens to generate.")] = 4096,
134
+ ) -> str:
135
+ """Complete a conversation with the host agent's model (Anthropic request shape)."""
136
+ return extract_text(completion)
137
+
138
+
139
+ @mcp.tool(
140
+ description="List the models this server offers. There is exactly one: agent-default, an alias for the host agent's own model."
141
+ )
142
+ async def list_models() -> str:
143
+ """List available models (the host's own model, aliased)."""
144
+ return json.dumps(
145
+ [
146
+ {
147
+ "id": "agent-default",
148
+ "owned_by": "host-agent",
149
+ "description": (
150
+ "Alias for the MCP host's own model, reached via sampling. No API key required."
151
+ ),
152
+ }
153
+ ]
154
+ )
155
+
156
+
157
+ def main() -> None:
158
+ mcp.run_stdio_async()
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-as-model
3
+ Version: 0.1.0
4
+ Summary: A true MCP server where the agent is the model and the API key.
5
+ Author: Fernando Garza
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/fernandogarzaaa/agent-as-model
8
+ Project-URL: Repository, https://github.com/fernandogarzaaa/agent-as-model
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: mcp<3,>=2
13
+ Dynamic: license-file
14
+
15
+ # agent-as-model
16
+
17
+ A true MCP server where the agent **is** the model and the API key.
18
+
19
+ Install it on any MCP host (Claude Code, Cursor, Windsurf, any spec-compliant
20
+ client). It exposes product-style completion tools. Every call is fulfilled by
21
+ sampling the host agent's own LLM. No API keys exist anywhere in this system.
22
+
23
+ ## The idea
24
+
25
+ AI products usually demand a model API key before they do anything useful.
26
+ This server flips that around. The tools speak the request shapes products
27
+ already use, and the intelligence comes from the agent on the other end of
28
+ the MCP connection. The agent is the model. The agent is the key.
29
+
30
+ ## Install
31
+
32
+ Requires Python 3.10+ and a host that declares the MCP `sampling`
33
+ capability. No API keys, ever.
34
+
35
+ Run it straight from the repo with uvx (no install step):
36
+
37
+ ```bash
38
+ claude mcp add agent-as-model -- uvx --from git+https://github.com/fernandogarzaaa/agent-as-model agent-as-model
39
+ ```
40
+
41
+ Or install it for real:
42
+
43
+ ```bash
44
+ pipx install git+https://github.com/fernandogarzaaa/agent-as-model
45
+ # then
46
+ claude mcp add agent-as-model -- agent-as-model
47
+ ```
48
+
49
+ Cursor, Windsurf, or any client that takes MCP JSON:
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "agent-as-model": {
55
+ "command": "uvx",
56
+ "args": ["--from", "git+https://github.com/fernandogarzaaa/agent-as-model", "agent-as-model"]
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ From a local checkout: `python -m agent_as_model`.
63
+
64
+ ## Tools
65
+
66
+ **chat_complete** — OpenAI chat-completions-shaped request, completion text back.
67
+
68
+ ```json
69
+ {
70
+ "messages": [
71
+ {"role": "system", "content": "You are terse."},
72
+ {"role": "user", "content": "Explain MCP sampling."}
73
+ ],
74
+ "model": "agent-default",
75
+ "temperature": 0.7,
76
+ "max_tokens": 500
77
+ }
78
+ ```
79
+
80
+ **messages_create** — Anthropic messages-shaped request, completion text back.
81
+ `system` accepts a string or a list of text blocks. Message content accepts a
82
+ string or a list of text blocks.
83
+
84
+ **list_models** — returns the one model this server offers: `agent-default`,
85
+ an alias for the host agent's own model.
86
+
87
+ ## How it works
88
+
89
+ 1. A product (or agent) calls `chat_complete` with ordinary chat messages.
90
+ 2. The server translates them into MCP sampling messages and asks the host
91
+ to complete them. On pre-2026 connections this is a direct
92
+ `sampling/createMessage` request. On 2026-07-28+ it rides the
93
+ multi-round-trip pattern. One tool body serves both eras.
94
+ 3. The host agent generates the completion with its own model and the server
95
+ returns the text. The `model` argument is passed to the host as a model
96
+ hint only; `agent-default` means "your own model".
97
+
98
+ ## Honest caveats
99
+
100
+ - Sampling is deprecated upstream (SEP-2577, spec 2026-07-28, earliest
101
+ removal 2027-07-28). The official migration path is provider API keys.
102
+ This project exists because the agent-as-model architecture is the point,
103
+ not the keys. The era-portable form used here is the sanctioned way to
104
+ keep it working across both spec eras.
105
+ - The host is in charge. Sampling runs under the host's consent and policy.
106
+ A host without the sampling capability gets a clear
107
+ `MISSING_REQUIRED_CLIENT_CAPABILITY` error, not a silent failure.
108
+ - Latency is one host model call per tool call. This is a thinking
109
+ endpoint, not a fast one.
110
+ - v1 serves text only. Tool calls, images, and audio in either direction
111
+ are rejected with explicit errors, not silently dropped.
112
+
113
+ ## Roadmap
114
+
115
+ - Provider-key fallback for hosts without sampling (opt-in, per-call choice
116
+ stays with the user).
117
+ - Structured output passthrough where hosts support it.
118
+ - More product shapes as tools (embeddings-shaped requests cannot be
119
+ sampled meaningfully; out of scope by design).
120
+
121
+ ## Development
122
+
123
+ ```bash
124
+ python3 -m pytest tests/ -q
125
+ python3 -m ruff format . && python3 -m ruff check .
126
+ ```
127
+
128
+ 14 tests, all green: message translation unit tests plus end-to-end runs of
129
+ the full MCP loop (tool call to sampled completion) against a scripted host
130
+ on both the modern multi-round-trip era and the legacy direct-sampling era.
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ agent_as_model/__init__.py
5
+ agent_as_model/__main__.py
6
+ agent_as_model/protocol.py
7
+ agent_as_model/server.py
8
+ agent_as_model.egg-info/PKG-INFO
9
+ agent_as_model.egg-info/SOURCES.txt
10
+ agent_as_model.egg-info/dependency_links.txt
11
+ agent_as_model.egg-info/entry_points.txt
12
+ agent_as_model.egg-info/requires.txt
13
+ agent_as_model.egg-info/top_level.txt
14
+ tests/test_protocol.py
15
+ tests/test_server.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-as-model = agent_as_model.server:main
@@ -0,0 +1 @@
1
+ mcp<3,>=2
@@ -0,0 +1 @@
1
+ agent_as_model
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "agent-as-model"
3
+ version = "0.1.0"
4
+ description = "A true MCP server where the agent is the model and the API key."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Fernando Garza" }]
9
+ dependencies = ["mcp>=2,<3"]
10
+
11
+ [project.scripts]
12
+ agent-as-model = "agent_as_model.server:main"
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/fernandogarzaaa/agent-as-model"
16
+ Repository = "https://github.com/fernandogarzaaa/agent-as-model"
17
+
18
+ [build-system]
19
+ requires = ["setuptools>=61"]
20
+ build-backend = "setuptools.build_meta"
21
+
22
+ [tool.setuptools.packages.find]
23
+ include = ["agent_as_model*"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+
28
+ [tool.ruff]
29
+ line-length = 100
30
+ target-version = "py310"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,99 @@
1
+ """Unit tests for the message-translation layer."""
2
+
3
+ import pytest
4
+ from mcp.types import CreateMessageResult, ImageContent, TextContent
5
+
6
+ from agent_as_model.protocol import (
7
+ AnthropicContentBlock,
8
+ AnthropicMessage,
9
+ ChatMessage,
10
+ anthropic_to_sampling,
11
+ extract_text,
12
+ openai_to_sampling,
13
+ )
14
+
15
+
16
+ def test_openai_system_messages_become_system_prompt():
17
+ system_prompt, sampling = openai_to_sampling(
18
+ [
19
+ ChatMessage(role="system", content="You are terse."),
20
+ ChatMessage(role="user", content="Hi"),
21
+ ChatMessage(role="system", content="No emojis."),
22
+ ChatMessage(role="assistant", content="Hello"),
23
+ ]
24
+ )
25
+ assert system_prompt == "You are terse.\n\nNo emojis."
26
+ assert [(m.role, m.content.text) for m in sampling] == [
27
+ ("user", "Hi"),
28
+ ("assistant", "Hello"),
29
+ ]
30
+
31
+
32
+ def test_openai_without_system_gives_none_prompt():
33
+ system_prompt, sampling = openai_to_sampling([ChatMessage(role="user", content="Hi")])
34
+ assert system_prompt is None
35
+ assert len(sampling) == 1
36
+
37
+
38
+ def test_openai_empty_messages_rejected():
39
+ with pytest.raises(ValueError, match="non-empty"):
40
+ openai_to_sampling([])
41
+
42
+
43
+ def test_anthropic_text_blocks_and_system_blocks():
44
+ system_prompt, sampling = anthropic_to_sampling(
45
+ [
46
+ AnthropicMessage(
47
+ role="user",
48
+ content=[
49
+ AnthropicContentBlock(type="text", text="First."),
50
+ AnthropicContentBlock(type="text", text="Second."),
51
+ ],
52
+ )
53
+ ],
54
+ system=[
55
+ AnthropicContentBlock(type="text", text="Be brief."),
56
+ AnthropicContentBlock(type="text", text="Be kind."),
57
+ ],
58
+ )
59
+ assert system_prompt == "Be brief.\nBe kind."
60
+ assert sampling[0].content.text == "First.\nSecond."
61
+
62
+
63
+ def test_anthropic_plain_strings():
64
+ system_prompt, sampling = anthropic_to_sampling(
65
+ [AnthropicMessage(role="user", content="Hello")], system="Shout."
66
+ )
67
+ assert system_prompt == "Shout."
68
+ assert sampling[0].content.text == "Hello"
69
+
70
+
71
+ def test_anthropic_no_system():
72
+ system_prompt, _ = anthropic_to_sampling([AnthropicMessage(role="user", content="Hello")], None)
73
+ assert system_prompt is None
74
+
75
+
76
+ def test_anthropic_empty_messages_rejected():
77
+ with pytest.raises(ValueError, match="non-empty"):
78
+ anthropic_to_sampling([], None)
79
+
80
+
81
+ def test_extract_text_returns_text():
82
+ result = CreateMessageResult(
83
+ role="assistant",
84
+ content=TextContent(type="text", text="done"),
85
+ model="m",
86
+ stop_reason="endTurn",
87
+ )
88
+ assert extract_text(result) == "done"
89
+
90
+
91
+ def test_extract_text_rejects_non_text():
92
+ result = CreateMessageResult(
93
+ role="assistant",
94
+ content=ImageContent(type="image", data="eA==", mime_type="image/png"),
95
+ model="m",
96
+ stop_reason="endTurn",
97
+ )
98
+ with pytest.raises(ValueError, match="text completions only"):
99
+ extract_text(result)
@@ -0,0 +1,167 @@
1
+ """End-to-end tests: an in-process MCP client drives the real server tools.
2
+
3
+ The scripted sampling callback stands in for the host agent's own model, so
4
+ these tests exercise the full loop a real platform install would run:
5
+ tools/call -> sampling request -> canned host completion -> tool result.
6
+ Both protocol eras are covered: the default (2026-07-28, multi-round-trip)
7
+ and legacy (direct sampling/createMessage).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ import anyio
15
+ import pytest
16
+ from mcp import types
17
+ from mcp.client import Client
18
+ from mcp.client.session import ClientRequestContext
19
+
20
+ import agent_as_model.server as agent_server
21
+
22
+ SERVER = agent_server.mcp
23
+
24
+
25
+ def _run(fn):
26
+ return anyio.run(fn)
27
+
28
+
29
+ async def _canned_sampler(
30
+ context: ClientRequestContext, params: types.CreateMessageRequestParams
31
+ ) -> types.CreateMessageResult:
32
+ """Stand in for the host agent: echo the wire params as the completion."""
33
+ seen = {
34
+ "system_prompt": params.system_prompt,
35
+ "max_tokens": params.max_tokens,
36
+ "temperature": params.temperature,
37
+ "messages": [(m.role, m.content.text) for m in params.messages],
38
+ "hints": [h.name for h in params.model_preferences.hints]
39
+ if params.model_preferences and params.model_preferences.hints
40
+ else [],
41
+ }
42
+ return types.CreateMessageResult(
43
+ role="assistant",
44
+ content=types.TextContent(type="text", text="CANNED:" + json.dumps(seen, sort_keys=True)),
45
+ model="test-host-model",
46
+ stop_reason="endTurn",
47
+ )
48
+
49
+
50
+ def _tool_text(result) -> str:
51
+ assert len(result.content) == 1
52
+ block = result.content[0]
53
+ assert block.type == "text"
54
+ return block.text
55
+
56
+
57
+ def test_tools_listed_and_resolve_param_hidden():
58
+ async def go():
59
+ async with Client(SERVER) as client:
60
+ tools = await client.list_tools()
61
+ names = {t.name for t in tools.tools}
62
+ assert {"chat_complete", "messages_create", "list_models"} <= names
63
+ for name in ("chat_complete", "messages_create"):
64
+ tool = next(t for t in tools.tools if t.name == name)
65
+ assert "completion" not in tool.input_schema.get("properties", {})
66
+
67
+ _run(go)
68
+
69
+
70
+ def test_chat_complete_modern_era():
71
+ async def go():
72
+ async with Client(SERVER, sampling_callback=_canned_sampler) as client:
73
+ result = await client.call_tool(
74
+ "chat_complete",
75
+ {
76
+ "messages": [
77
+ {"role": "system", "content": "You are terse."},
78
+ {"role": "user", "content": "Say hi."},
79
+ ],
80
+ "model": "agent-default",
81
+ "temperature": 0.5,
82
+ "max_tokens": 100,
83
+ },
84
+ )
85
+ seen = json.loads(_tool_text(result).removeprefix("CANNED:"))
86
+ assert seen["system_prompt"] == "You are terse."
87
+ assert seen["messages"] == [["user", "Say hi."]]
88
+ assert seen["max_tokens"] == 100
89
+ assert seen["temperature"] == 0.5
90
+ assert seen["hints"] == ["agent-default"]
91
+
92
+ _run(go)
93
+
94
+
95
+ def test_chat_complete_legacy_era():
96
+ async def go():
97
+ async with Client(SERVER, mode="legacy", sampling_callback=_canned_sampler) as client:
98
+ result = await client.call_tool(
99
+ "chat_complete",
100
+ {"messages": [{"role": "user", "content": "Ping"}]},
101
+ )
102
+ seen = json.loads(_tool_text(result).removeprefix("CANNED:"))
103
+ assert seen["messages"] == [["user", "Ping"]]
104
+ assert seen["system_prompt"] is None
105
+
106
+ _run(go)
107
+
108
+
109
+ def test_messages_create_anthropic_shape():
110
+ async def go():
111
+ async with Client(SERVER, sampling_callback=_canned_sampler) as client:
112
+ result = await client.call_tool(
113
+ "messages_create",
114
+ {
115
+ "model": "agent-default",
116
+ "system": "Be brief.",
117
+ "messages": [{"role": "user", "content": "Summarize this."}],
118
+ "max_tokens": 50,
119
+ },
120
+ )
121
+ seen = json.loads(_tool_text(result).removeprefix("CANNED:"))
122
+ assert seen["system_prompt"] == "Be brief."
123
+ assert seen["messages"] == [["user", "Summarize this."]]
124
+
125
+ _run(go)
126
+
127
+
128
+ def test_list_models():
129
+ async def go():
130
+ async with Client(SERVER) as client:
131
+ result = await client.call_tool("list_models", {})
132
+ models = json.loads(_tool_text(result))
133
+ assert models[0]["id"] == "agent-default"
134
+
135
+ _run(go)
136
+
137
+
138
+ def test_default_max_tokens_reaches_host():
139
+ async def go():
140
+ async with Client(SERVER, sampling_callback=_canned_sampler) as client:
141
+ for tool_name, args in (
142
+ ("chat_complete", {"messages": [{"role": "user", "content": "Hi"}]}),
143
+ ("messages_create", {"messages": [{"role": "user", "content": "Hi"}]}),
144
+ ):
145
+ result = await client.call_tool(tool_name, args)
146
+ seen = json.loads(_tool_text(result).removeprefix("CANNED:"))
147
+ assert seen["max_tokens"] == 4096
148
+
149
+ _run(go)
150
+
151
+
152
+ def test_main_runs_stdio(monkeypatch):
153
+ calls = []
154
+ monkeypatch.setattr(SERVER, "run_stdio_async", lambda: calls.append(True))
155
+ agent_server.main()
156
+ assert calls == [True]
157
+
158
+
159
+ def test_missing_sampling_capability_fails_clearly():
160
+ async def go():
161
+ async with Client(SERVER) as client: # no sampling_callback
162
+ with pytest.raises(Exception, match="(?i)sampling"):
163
+ await client.call_tool(
164
+ "chat_complete", {"messages": [{"role": "user", "content": "Hi"}]}
165
+ )
166
+
167
+ _run(go)