npc-protocol 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,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: npc-protocol
3
+ Version: 0.1.0
4
+ Summary: Python SDK for building NPC Protocol servers
5
+ Project-URL: Homepage, https://github.com/macropulse/npc-protocol
6
+ Project-URL: Documentation, https://github.com/macropulse/npc-protocol/tree/main/spec
7
+ Project-URL: Repository, https://github.com/macropulse/npc-protocol
8
+ Project-URL: Issues, https://github.com/macropulse/npc-protocol/issues
9
+ License: Apache-2.0
10
+ Keywords: agent,ai,mcp,npc,protocol
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.9
20
+ Requires-Dist: mcp>=1.0.0
21
+ Requires-Dist: pydantic>=2.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
25
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
26
+ Provides-Extra: redis
27
+ Requires-Dist: redis>=5.0.0; extra == 'redis'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # npc-protocol
31
+
32
+ Python SDK for building [NPC Protocol](https://github.com/macropulse/npc-protocol) servers.
33
+
34
+ **Stop selling APIs. Wrap yourself into an NPC and let it earn for you while you sleep.**
35
+
36
+ ---
37
+
38
+ ## What is NPC Protocol?
39
+
40
+ NPC Protocol is an open standard for packaging domain knowledge as a "black box" agent service that AI agents can delegate to — in natural language, with persistent memory and explicit safety contracts. Built on top of MCP.
41
+
42
+ > *Your coworker became a skill. Your ex became a skill. Now it's your turn.*
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install npc-protocol
48
+ ```
49
+
50
+ ## Quick Start
51
+
52
+ ```python
53
+ from npc import NPCServer, NPCCard, NPCContext
54
+ from npc.response import GateLevel
55
+
56
+ card = NPCCard(
57
+ name="DNS Manager",
58
+ domain="DNS record management",
59
+ confirmation_gates=[
60
+ {"id": "delete_zone", "level": "destructive", "description": "Deleting a DNS zone"}
61
+ ],
62
+ )
63
+
64
+ npc = NPCServer(card=card)
65
+
66
+ @npc.instruction_handler
67
+ async def handle(instruction: str, session_id: str, ctx: NPCContext):
68
+ if "delete" in instruction.lower():
69
+ return ctx.require_confirmation(
70
+ gate_id="delete_zone",
71
+ gate_level=GateLevel.DESTRUCTIVE,
72
+ prompt="This will permanently delete the zone and all its records.",
73
+ protected_resources=[
74
+ {"type": "dns_zone", "name": "example.com", "risk": "All DNS records deleted"}
75
+ ],
76
+ )
77
+ return ctx.complete(result="DNS record updated")
78
+
79
+ npc.run()
80
+ ```
81
+
82
+ ## Links
83
+
84
+ - [Full spec and docs](https://github.com/macropulse/npc-protocol)
85
+ - [NPC Card spec](https://github.com/macropulse/npc-protocol/blob/main/spec/npc-card.md)
86
+ - [Confirmation Gates spec](https://github.com/macropulse/npc-protocol/blob/main/spec/confirmation-gate.md)
87
+
88
+ ## License
89
+
90
+ Apache 2.0
@@ -0,0 +1,61 @@
1
+ # npc-protocol
2
+
3
+ Python SDK for building [NPC Protocol](https://github.com/macropulse/npc-protocol) servers.
4
+
5
+ **Stop selling APIs. Wrap yourself into an NPC and let it earn for you while you sleep.**
6
+
7
+ ---
8
+
9
+ ## What is NPC Protocol?
10
+
11
+ NPC Protocol is an open standard for packaging domain knowledge as a "black box" agent service that AI agents can delegate to — in natural language, with persistent memory and explicit safety contracts. Built on top of MCP.
12
+
13
+ > *Your coworker became a skill. Your ex became a skill. Now it's your turn.*
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install npc-protocol
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```python
24
+ from npc import NPCServer, NPCCard, NPCContext
25
+ from npc.response import GateLevel
26
+
27
+ card = NPCCard(
28
+ name="DNS Manager",
29
+ domain="DNS record management",
30
+ confirmation_gates=[
31
+ {"id": "delete_zone", "level": "destructive", "description": "Deleting a DNS zone"}
32
+ ],
33
+ )
34
+
35
+ npc = NPCServer(card=card)
36
+
37
+ @npc.instruction_handler
38
+ async def handle(instruction: str, session_id: str, ctx: NPCContext):
39
+ if "delete" in instruction.lower():
40
+ return ctx.require_confirmation(
41
+ gate_id="delete_zone",
42
+ gate_level=GateLevel.DESTRUCTIVE,
43
+ prompt="This will permanently delete the zone and all its records.",
44
+ protected_resources=[
45
+ {"type": "dns_zone", "name": "example.com", "risk": "All DNS records deleted"}
46
+ ],
47
+ )
48
+ return ctx.complete(result="DNS record updated")
49
+
50
+ npc.run()
51
+ ```
52
+
53
+ ## Links
54
+
55
+ - [Full spec and docs](https://github.com/macropulse/npc-protocol)
56
+ - [NPC Card spec](https://github.com/macropulse/npc-protocol/blob/main/spec/npc-card.md)
57
+ - [Confirmation Gates spec](https://github.com/macropulse/npc-protocol/blob/main/spec/confirmation-gate.md)
58
+
59
+ ## License
60
+
61
+ Apache 2.0
@@ -0,0 +1,169 @@
1
+ # SwiftDeploy — NPC Protocol Case Study
2
+
3
+ [SwiftDeploy](https://swiftdeploy.ai) is a cloud infrastructure deployment service that independently converged on the NPC Protocol pattern before the spec was written. It is used here as the production reference implementation to demonstrate what a real-world NPC looks like at scale.
4
+
5
+ ---
6
+
7
+ ## NPC Card
8
+
9
+ ```json
10
+ {
11
+ "npc_protocol_version": "0.1.0",
12
+ "name": "SwiftDeploy",
13
+ "version": "1.5.0",
14
+ "domain": "cloud infrastructure deployment and management on AWS",
15
+ "description": "Deploys, manages, and decommissions cloud infrastructure. Handles ECS, RDS, Redis, S3, ACM, Route53, and related services via OpenTofu (Terraform-compatible). Maintains project memory across sessions.",
16
+ "instruction_interface": "natural_language",
17
+ "capability_opacity": "opaque",
18
+ "session_model": "persistent",
19
+ "confirmation_gates": [
20
+ {
21
+ "id": "production_deploy",
22
+ "level": "required",
23
+ "description": "Deploying to a production environment (affects live traffic)"
24
+ },
25
+ {
26
+ "id": "decommission",
27
+ "level": "destructive",
28
+ "description": "Destroying all infrastructure for an environment, including stateful resources"
29
+ }
30
+ ],
31
+ "pricing_hints": {
32
+ "model": "credits",
33
+ "unit": "session",
34
+ "approximate_cost": "10-50 credits"
35
+ },
36
+ "contact": "https://swiftdeploy.ai",
37
+ "mcp_tools": ["execute", "get_status", "write_memory", "cancel_deployment"]
38
+ }
39
+ ```
40
+
41
+ Note that SwiftDeploy exposes additional tools beyond `execute` (`get_status`, `write_memory`, `cancel_deployment`). This is valid — the spec requires `execute` for natural language instructions but does not restrict additional tools.
42
+
43
+ ---
44
+
45
+ ## Interaction Trace: Full Deployment Flow
46
+
47
+ This is a real interaction trace showing the NPC Protocol primitives in action.
48
+
49
+ ### Step 1: Calling agent reads the NPC Card
50
+
51
+ ```
52
+ GET npc://card
53
+ --> NPC Card JSON (above)
54
+ ```
55
+
56
+ The calling agent now knows: domain is cloud infrastructure, session is persistent, two confirmation gates exist (production_deploy: required, decommission: destructive).
57
+
58
+ ### Step 2: Initial instruction
59
+
60
+ ```
61
+ execute(instruction="deploy my app to staging with mysql and redis")
62
+ --> {
63
+ "type": "in_progress",
64
+ "session_id": "sess_a1b2c3d4",
65
+ "message": "No infrastructure found for this project. Generating infrastructure plan first...",
66
+ "progress": { "step": "generating_infrastructure" }
67
+ }
68
+ ```
69
+
70
+ The NPC detects that no infrastructure exists yet (reads project memory) and autonomously decides to generate it before deploying. The calling agent does not need to know this — it just sees `in_progress`.
71
+
72
+ ### Step 3: Polling for progress
73
+
74
+ ```
75
+ get_status(project_id="...", environment="staging")
76
+ --> {
77
+ "status": "deploying",
78
+ "current_step": "aws_ecs_service.app: Still creating...",
79
+ "elapsed_seconds": 87,
80
+ "recent_logs": [...]
81
+ }
82
+ ```
83
+
84
+ ### Step 4: Completion
85
+
86
+ ```
87
+ get_status(project_id="...", environment="staging")
88
+ --> {
89
+ "type": "completed",
90
+ "session_id": "sess_a1b2c3d4",
91
+ "result": "Staging environment deployed successfully",
92
+ "data": {
93
+ "app_url": "https://my-app-staging.app.swiftdeploy.ai",
94
+ "ecr_push_command": "docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest"
95
+ }
96
+ }
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Interaction Trace: Decommission with Destructive Gate
102
+
103
+ ### Step 1: Instruction
104
+
105
+ ```
106
+ execute(instruction="tear down the staging environment")
107
+ --> {
108
+ "type": "needs_confirmation",
109
+ "session_id": "sess_x9y8z7w6",
110
+ "gate_id": "decommission",
111
+ "gate_level": "destructive",
112
+ "prompt": "This will permanently destroy all infrastructure for 'my-app' in staging.",
113
+ "protected_resources": [
114
+ {
115
+ "type": "aws_rds_instance",
116
+ "name": "my-app-staging-db",
117
+ "risk": "All database data will be permanently deleted"
118
+ },
119
+ {
120
+ "type": "aws_secretsmanager_secret",
121
+ "name": "my-app-staging-credentials",
122
+ "risk": "Credentials will be permanently deleted"
123
+ }
124
+ ]
125
+ }
126
+ ```
127
+
128
+ The calling agent MUST show this full list to the human user. It cannot auto-confirm.
129
+
130
+ ### Step 2: Human confirms, calling agent relays
131
+
132
+ ```
133
+ execute(instruction="yes, confirmed", session_id="sess_x9y8z7w6", confirm=true)
134
+ --> {
135
+ "type": "completed",
136
+ "session_id": "sess_x9y8z7w6",
137
+ "result": "Staging environment decommissioned. All resources destroyed."
138
+ }
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Memory Architecture
144
+
145
+ SwiftDeploy uses all three memory tiers defined in the NPC Protocol session model:
146
+
147
+ | Tier | What SwiftDeploy stores |
148
+ |---|---|
149
+ | **Ephemeral** | In-flight terraform plan, intermediate reasoning steps |
150
+ | **Session** | Current deployment conversation: what was asked, what decisions were made, pending confirmations |
151
+ | **Persistent** | Project memory: AWS account ID, architecture plan, deployment history, known issues, manual changes recorded via `write_memory` |
152
+
153
+ The persistent memory is what enables the NPC to say things like "you already have an RDS instance from your last deployment — I'll reuse it" or "last time you deployed this, the Secrets Manager deletion window caused a 7-day delay — I'll handle that automatically this time."
154
+
155
+ ---
156
+
157
+ ## What Was Hard Without the Spec
158
+
159
+ Before NPC Protocol named these patterns, SwiftDeploy had to:
160
+
161
+ 1. **Invent its own confirmation gate format** — the `needs_confirmation` / `needs_decommission_confirmation` response types were invented ad-hoc. Calling agents had to read SwiftDeploy-specific documentation to understand them.
162
+
163
+ 2. **Document its session model informally** — there was no standard way to communicate "I maintain persistent project memory" to a calling agent. This was buried in the skills file.
164
+
165
+ 3. **Explain `recovery_hint` as a custom convention** — the fact that every `failed` response includes a recovery hint was a SwiftDeploy design decision, not a known standard.
166
+
167
+ 4. **Define capability opacity without a term for it** — SwiftDeploy's `execute` tool intentionally doesn't expose its internal tool list, but there was no standard way to declare this. Calling agents sometimes tried to enumerate tools and got confused.
168
+
169
+ NPC Protocol names all of these patterns and makes them discoverable via the NPC Card, so calling agents can handle them generically without reading NPC-specific documentation.
@@ -0,0 +1,73 @@
1
+ """
2
+ NPC Protocol — Python SDK
3
+
4
+ Build domain-expert agent services that AI agents can delegate to.
5
+
6
+ Quick start:
7
+
8
+ from npc import NPCServer, NPCCard, NPCContext
9
+ from npc.response import GateLevel
10
+
11
+ card = NPCCard(
12
+ name="My NPC",
13
+ domain="describe what your NPC does",
14
+ )
15
+
16
+ npc = NPCServer(card=card)
17
+
18
+ @npc.instruction_handler
19
+ async def handle(instruction: str, session_id: str, ctx: NPCContext):
20
+ return ctx.complete(result="Done")
21
+
22
+ npc.run()
23
+ """
24
+
25
+ from .card import (
26
+ CapabilityOpacity,
27
+ ConfirmationGateDeclaration,
28
+ InstructionInterface,
29
+ NPCCard,
30
+ PricingHints,
31
+ PricingModel,
32
+ SessionModelType,
33
+ )
34
+ from .context import NPCContext
35
+ from .response import (
36
+ CompletedResponse,
37
+ FailedResponse,
38
+ GateLevel,
39
+ InProgressResponse,
40
+ NeedsConfirmationResponse,
41
+ NeedsInputResponse,
42
+ NPCResponse,
43
+ ProtectedResource,
44
+ ResponseType,
45
+ )
46
+ from .server import NPCServer
47
+ from .session import InMemorySessionStore, SessionStore
48
+
49
+ __version__ = "0.1.0"
50
+ __all__ = [
51
+ "NPCServer",
52
+ "NPCCard",
53
+ "NPCContext",
54
+ "SessionStore",
55
+ "InMemorySessionStore",
56
+ # Response types
57
+ "NPCResponse",
58
+ "InProgressResponse",
59
+ "NeedsInputResponse",
60
+ "NeedsConfirmationResponse",
61
+ "CompletedResponse",
62
+ "FailedResponse",
63
+ "ProtectedResource",
64
+ # Enums
65
+ "ResponseType",
66
+ "GateLevel",
67
+ "InstructionInterface",
68
+ "CapabilityOpacity",
69
+ "SessionModelType",
70
+ "PricingModel",
71
+ "ConfirmationGateDeclaration",
72
+ "PricingHints",
73
+ ]
@@ -0,0 +1,87 @@
1
+ """
2
+ NPC Card — the identity and capability declaration for an NPC server.
3
+
4
+ Published as an MCP resource at npc://card.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import Enum
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ class InstructionInterface(str, Enum):
16
+ NATURAL_LANGUAGE = "natural_language"
17
+ STRUCTURED = "structured"
18
+
19
+
20
+ class CapabilityOpacity(str, Enum):
21
+ OPAQUE = "opaque"
22
+ TRANSPARENT = "transparent"
23
+
24
+
25
+ class SessionModelType(str, Enum):
26
+ EPHEMERAL = "ephemeral"
27
+ SESSION = "session"
28
+ PERSISTENT = "persistent"
29
+
30
+
31
+ class GateLevel(str, Enum):
32
+ ADVISORY = "advisory"
33
+ REQUIRED = "required"
34
+ DESTRUCTIVE = "destructive"
35
+
36
+
37
+ class PricingModel(str, Enum):
38
+ CREDITS = "credits"
39
+ SUBSCRIPTION = "subscription"
40
+ PAY_PER_USE = "pay_per_use"
41
+ FREE = "free"
42
+
43
+
44
+ class ConfirmationGateDeclaration(BaseModel):
45
+ id: str = Field(..., description="Machine-readable gate identifier")
46
+ level: GateLevel
47
+ description: str = Field(..., description="Human-readable explanation of when this gate triggers")
48
+
49
+
50
+ class PricingHints(BaseModel):
51
+ model: PricingModel
52
+ unit: str = Field(..., description="Billing unit: session, instruction, token, minute")
53
+ approximate_cost: str | None = None
54
+ free_tier: str | None = None
55
+
56
+
57
+ class NPCCard(BaseModel):
58
+ """
59
+ The NPC Card identifies an NPC server and declares its capabilities.
60
+ Published as an MCP resource at npc://card.
61
+ """
62
+
63
+ npc_protocol_version: str = Field(default="0.1.0", description="NPC Protocol spec version")
64
+ name: str = Field(..., description="Human-readable name of the NPC service")
65
+ domain: str = Field(..., description="Plain-language description of the domain this NPC covers")
66
+ instruction_interface: InstructionInterface = InstructionInterface.NATURAL_LANGUAGE
67
+ capability_opacity: CapabilityOpacity = CapabilityOpacity.OPAQUE
68
+ session_model: SessionModelType = SessionModelType.SESSION
69
+ mcp_tools: list[str] = Field(default_factory=lambda: ["execute"])
70
+
71
+ # Optional fields
72
+ version: str | None = None
73
+ description: str | None = None
74
+ confirmation_gates: list[ConfirmationGateDeclaration] = Field(default_factory=list)
75
+ pricing_hints: PricingHints | None = None
76
+ contact: str | None = None
77
+
78
+ def model_post_init(self, __context: Any) -> None:
79
+ # Enforce spec: natural_language NPCs must expose execute
80
+ if (
81
+ self.instruction_interface == InstructionInterface.NATURAL_LANGUAGE
82
+ and "execute" not in self.mcp_tools
83
+ ):
84
+ self.mcp_tools = ["execute", *self.mcp_tools]
85
+
86
+ def to_json(self) -> dict[str, Any]:
87
+ return self.model_dump(exclude_none=True)
@@ -0,0 +1,144 @@
1
+ """
2
+ NPCContext — per-request context passed to instruction handlers.
3
+
4
+ Provides helper methods to construct spec-compliant responses without
5
+ manually building response dicts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .response import (
13
+ CompletedResponse,
14
+ FailedResponse,
15
+ GateLevel,
16
+ InProgressResponse,
17
+ NeedsConfirmationResponse,
18
+ NeedsInputResponse,
19
+ NPCResponse,
20
+ ProtectedResource,
21
+ )
22
+ from .session import SessionStore
23
+
24
+
25
+ class NPCContext:
26
+ """
27
+ Context object injected into every instruction handler call.
28
+
29
+ Provides the current session state and helper methods for building
30
+ spec-compliant NPC Protocol responses.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ session_id: str,
36
+ session_store: SessionStore,
37
+ session_data: dict[str, Any],
38
+ ) -> None:
39
+ self.session_id = session_id
40
+ self._store = session_store
41
+ self._data = session_data
42
+
43
+ # -- Session helpers --
44
+
45
+ def get(self, key: str, default: Any = None) -> Any:
46
+ """Get a value from the current session data."""
47
+ return self._data.get(key, default)
48
+
49
+ async def set(self, key: str, value: Any) -> None:
50
+ """Persist a value to the current session."""
51
+ self._data[key] = value
52
+ await self._store.update(self.session_id, {key: value})
53
+
54
+ async def clear(self) -> None:
55
+ """Terminate the current session."""
56
+ await self._store.delete(self.session_id)
57
+
58
+ # -- Response builders --
59
+
60
+ def in_progress(
61
+ self,
62
+ message: str,
63
+ progress: dict[str, Any] | None = None,
64
+ ) -> NPCResponse:
65
+ return InProgressResponse(
66
+ session_id=self.session_id,
67
+ message=message,
68
+ progress=progress,
69
+ )
70
+
71
+ def needs_input(
72
+ self,
73
+ question: str,
74
+ options: list[str] | None = None,
75
+ message: str | None = None,
76
+ ) -> NPCResponse:
77
+ return NeedsInputResponse(
78
+ session_id=self.session_id,
79
+ question=question,
80
+ options=options,
81
+ message=message,
82
+ )
83
+
84
+ def require_confirmation(
85
+ self,
86
+ gate_id: str,
87
+ gate_level: GateLevel,
88
+ prompt: str,
89
+ details: dict[str, Any] | None = None,
90
+ protected_resources: list[dict[str, str]] | None = None,
91
+ message: str | None = None,
92
+ ) -> NPCResponse:
93
+ """
94
+ Emit a confirmation gate. The calling agent must resolve this before
95
+ the NPC can proceed.
96
+
97
+ For gate_level=DESTRUCTIVE, protected_resources should list all
98
+ resources that will be permanently affected.
99
+ """
100
+ resources = None
101
+ if protected_resources:
102
+ resources = [ProtectedResource(**r) for r in protected_resources]
103
+
104
+ return NeedsConfirmationResponse(
105
+ session_id=self.session_id,
106
+ gate_id=gate_id,
107
+ gate_level=gate_level,
108
+ prompt=prompt,
109
+ details=details,
110
+ protected_resources=resources,
111
+ message=message,
112
+ )
113
+
114
+ def complete(
115
+ self,
116
+ result: str,
117
+ data: dict[str, Any] | None = None,
118
+ message: str | None = None,
119
+ ) -> NPCResponse:
120
+ return CompletedResponse(
121
+ session_id=self.session_id,
122
+ result=result,
123
+ data=data,
124
+ message=message,
125
+ )
126
+
127
+ def fail(
128
+ self,
129
+ error: str,
130
+ recovery_hint: str,
131
+ retryable: bool = False,
132
+ message: str | None = None,
133
+ ) -> NPCResponse:
134
+ """
135
+ Return a failure response. recovery_hint is required by the NPC Protocol spec
136
+ and must always be a concrete, actionable instruction.
137
+ """
138
+ return FailedResponse(
139
+ session_id=self.session_id,
140
+ error=error,
141
+ recovery_hint=recovery_hint,
142
+ retryable=retryable,
143
+ message=message,
144
+ )
@@ -0,0 +1,74 @@
1
+ """
2
+ Standard response types for NPC Protocol.
3
+
4
+ All execute() calls return one of these response envelopes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import Enum
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ class ResponseType(str, Enum):
16
+ IN_PROGRESS = "in_progress"
17
+ NEEDS_INPUT = "needs_input"
18
+ NEEDS_CONFIRMATION = "needs_confirmation"
19
+ COMPLETED = "completed"
20
+ FAILED = "failed"
21
+
22
+
23
+ class GateLevel(str, Enum):
24
+ ADVISORY = "advisory"
25
+ REQUIRED = "required"
26
+ DESTRUCTIVE = "destructive"
27
+
28
+
29
+ class ProtectedResource(BaseModel):
30
+ type: str = Field(..., description="Resource type identifier (e.g. 'aws_rds_instance')")
31
+ name: str = Field(..., description="Resource name or identifier")
32
+ risk: str = Field(..., description="Human-readable description of what will be permanently lost")
33
+
34
+
35
+ class NPCResponse(BaseModel):
36
+ type: ResponseType
37
+ session_id: str | None = None
38
+ message: str | None = None
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ return self.model_dump(exclude_none=True)
42
+
43
+
44
+ class InProgressResponse(NPCResponse):
45
+ type: ResponseType = ResponseType.IN_PROGRESS
46
+ progress: dict[str, Any] | None = None
47
+
48
+
49
+ class NeedsInputResponse(NPCResponse):
50
+ type: ResponseType = ResponseType.NEEDS_INPUT
51
+ question: str
52
+ options: list[str] | None = None
53
+
54
+
55
+ class NeedsConfirmationResponse(NPCResponse):
56
+ type: ResponseType = ResponseType.NEEDS_CONFIRMATION
57
+ gate_id: str
58
+ gate_level: GateLevel
59
+ prompt: str
60
+ details: dict[str, Any] | None = None
61
+ protected_resources: list[ProtectedResource] | None = None
62
+
63
+
64
+ class CompletedResponse(NPCResponse):
65
+ type: ResponseType = ResponseType.COMPLETED
66
+ result: str
67
+ data: dict[str, Any] | None = None
68
+
69
+
70
+ class FailedResponse(NPCResponse):
71
+ type: ResponseType = ResponseType.FAILED
72
+ error: str
73
+ recovery_hint: str # Required by spec — always present on failed
74
+ retryable: bool = False
@@ -0,0 +1,197 @@
1
+ """
2
+ NPCServer — the core class for building NPC Protocol servers.
3
+
4
+ Wraps an MCP server and automatically:
5
+ - Registers the NPC Card as an MCP resource at npc://card
6
+ - Registers the execute tool with the standard NPC Protocol schema
7
+ - Manages sessions via the provided SessionStore
8
+ - Routes execute calls to the registered instruction handler
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from collections.abc import Callable, Coroutine
15
+ from typing import Any
16
+
17
+ from mcp.server import Server
18
+ from mcp.server.stdio import stdio_server
19
+ from mcp.types import (
20
+ CallToolResult,
21
+ ReadResourceResult,
22
+ Resource,
23
+ TextContent,
24
+ TextResourceContents,
25
+ Tool,
26
+ )
27
+
28
+ from .card import NPCCard
29
+ from .context import NPCContext
30
+ from .response import FailedResponse, NPCResponse
31
+ from .session import SessionStore
32
+
33
+ # Type for the instruction handler function
34
+ InstructionHandler = Callable[
35
+ [str, str, NPCContext],
36
+ Coroutine[Any, Any, NPCResponse],
37
+ ]
38
+
39
+
40
+ class NPCServer:
41
+ """
42
+ An NPC Protocol server. Wraps an MCP server with protocol scaffolding.
43
+
44
+ Usage:
45
+
46
+ card = NPCCard(
47
+ name="My NPC",
48
+ domain="your domain description",
49
+ )
50
+
51
+ npc = NPCServer(card=card)
52
+
53
+ @npc.instruction_handler
54
+ async def handle(instruction: str, session_id: str, ctx: NPCContext) -> NPCResponse:
55
+ # Your domain logic here
56
+ return ctx.complete(result="Done")
57
+
58
+ npc.run() # starts the MCP server on stdio
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ card: NPCCard,
64
+ session_store: SessionStore | None = None,
65
+ ) -> None:
66
+ self.card = card
67
+ self._session_store = session_store or SessionStore.in_memory()
68
+ self._handler: InstructionHandler | None = None
69
+ self._mcp = Server(card.name)
70
+ self._register_mcp_handlers()
71
+
72
+ def instruction_handler(self, fn: InstructionHandler) -> InstructionHandler:
73
+ """Decorator to register the instruction handler for this NPC."""
74
+ self._handler = fn
75
+ return fn
76
+
77
+ def _register_mcp_handlers(self) -> None:
78
+ mcp = self._mcp
79
+
80
+ @mcp.list_resources()
81
+ async def list_resources() -> list[Resource]:
82
+ return [
83
+ Resource(
84
+ uri="npc://card",
85
+ name="NPC Card",
86
+ description=f"Identity and capability declaration for {self.card.name}",
87
+ mimeType="application/json",
88
+ )
89
+ ]
90
+
91
+ @mcp.read_resource()
92
+ async def read_resource(uri: str) -> ReadResourceResult:
93
+ if uri == "npc://card":
94
+ return ReadResourceResult(
95
+ contents=[
96
+ TextResourceContents(
97
+ uri="npc://card",
98
+ mimeType="application/json",
99
+ text=json.dumps(self.card.to_json(), indent=2),
100
+ )
101
+ ]
102
+ )
103
+ raise ValueError(f"Unknown resource: {uri}")
104
+
105
+ @mcp.list_tools()
106
+ async def list_tools() -> list[Tool]:
107
+ tools = [
108
+ Tool(
109
+ name="execute",
110
+ description=(
111
+ f"Send a natural language instruction to {self.card.name}. "
112
+ f"Domain: {self.card.domain}"
113
+ ),
114
+ inputSchema={
115
+ "type": "object",
116
+ "required": ["instruction"],
117
+ "properties": {
118
+ "instruction": {
119
+ "type": "string",
120
+ "description": "A natural language instruction or reply",
121
+ },
122
+ "session_id": {
123
+ "type": "string",
124
+ "description": "Resume an existing session. Omit to start a new session.",
125
+ },
126
+ "confirm": {
127
+ "type": "boolean",
128
+ "description": "Confirmation reply to a needs_confirmation gate",
129
+ },
130
+ "choice": {
131
+ "type": "string",
132
+ "description": "Selection reply to a needs_input response with options",
133
+ },
134
+ },
135
+ },
136
+ )
137
+ ]
138
+ return tools
139
+
140
+ @mcp.call_tool()
141
+ async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
142
+ if name != "execute":
143
+ raise ValueError(f"Unknown tool: {name}")
144
+ return await self._handle_execute(arguments)
145
+
146
+ async def _handle_execute(self, arguments: dict[str, Any]) -> CallToolResult:
147
+ if self._handler is None:
148
+ raise RuntimeError("No instruction handler registered. Use @npc.instruction_handler.")
149
+
150
+ instruction = arguments.get("instruction", "")
151
+ session_id = arguments.get("session_id")
152
+
153
+ # Resolve or create session
154
+ if session_id and await self._session_store.exists(session_id):
155
+ session_data = (await self._session_store.get(session_id)) or {}
156
+ else:
157
+ session_id = await self._session_store.create(
158
+ data={
159
+ "confirm": arguments.get("confirm"),
160
+ "choice": arguments.get("choice"),
161
+ }
162
+ )
163
+ session_data = {}
164
+
165
+ ctx = NPCContext(
166
+ session_id=session_id,
167
+ session_store=self._session_store,
168
+ session_data=session_data,
169
+ )
170
+
171
+ try:
172
+ response: NPCResponse = await self._handler(instruction, session_id, ctx)
173
+ except Exception as e:
174
+ response = FailedResponse(
175
+ session_id=session_id,
176
+ error=str(e),
177
+ recovery_hint="An unexpected error occurred. Please try again or contact support.",
178
+ )
179
+
180
+ return CallToolResult(
181
+ content=[
182
+ TextContent(
183
+ type="text",
184
+ text=json.dumps(response.to_dict(), indent=2),
185
+ )
186
+ ]
187
+ )
188
+
189
+ def run(self) -> None:
190
+ """Start the NPC server using stdio transport (default MCP transport)."""
191
+ import asyncio
192
+
193
+ async def _run() -> None:
194
+ async with stdio_server() as (read_stream, write_stream):
195
+ await self._mcp.run(read_stream, write_stream, self._mcp.create_initialization_options())
196
+
197
+ asyncio.run(_run())
@@ -0,0 +1,109 @@
1
+ """
2
+ Session store interface and built-in implementations.
3
+
4
+ Sessions are NPC-owned. The calling agent only carries the opaque session_id token.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ import uuid
11
+ from abc import ABC, abstractmethod
12
+ from typing import Any
13
+
14
+
15
+ class SessionStore(ABC):
16
+ """
17
+ Abstract interface for NPC session storage.
18
+ Implement this to plug in Redis, a database, or any other backend.
19
+ """
20
+
21
+ @abstractmethod
22
+ async def create(self, data: dict[str, Any] | None = None) -> str:
23
+ """Create a new session and return its session_id."""
24
+ ...
25
+
26
+ @abstractmethod
27
+ async def get(self, session_id: str) -> dict[str, Any] | None:
28
+ """
29
+ Retrieve session data by session_id.
30
+ Returns None if the session does not exist or has expired.
31
+ """
32
+ ...
33
+
34
+ @abstractmethod
35
+ async def update(self, session_id: str, data: dict[str, Any]) -> bool:
36
+ """
37
+ Update session data. Returns False if the session does not exist.
38
+ """
39
+ ...
40
+
41
+ @abstractmethod
42
+ async def delete(self, session_id: str) -> bool:
43
+ """Terminate a session. Returns False if it did not exist."""
44
+ ...
45
+
46
+ @abstractmethod
47
+ async def exists(self, session_id: str) -> bool:
48
+ """Check whether a session exists and has not expired."""
49
+ ...
50
+
51
+ @classmethod
52
+ def in_memory(cls, ttl_seconds: int = 86400) -> "InMemorySessionStore":
53
+ """Factory: create an in-memory session store (default, suitable for development)."""
54
+ return InMemorySessionStore(ttl_seconds=ttl_seconds)
55
+
56
+
57
+ class InMemorySessionStore(SessionStore):
58
+ """
59
+ Simple in-memory session store. Not suitable for production multi-process deployments.
60
+ Sessions expire after ttl_seconds of inactivity (default 24 hours).
61
+ """
62
+
63
+ def __init__(self, ttl_seconds: int = 86400) -> None:
64
+ self._store: dict[str, dict[str, Any]] = {}
65
+ self._ttl = ttl_seconds
66
+
67
+ def _is_expired(self, session: dict[str, Any]) -> bool:
68
+ return time.time() - session.get("_last_accessed", 0) > self._ttl
69
+
70
+ async def create(self, data: dict[str, Any] | None = None) -> str:
71
+ session_id = f"sess_{uuid.uuid4().hex[:16]}"
72
+ self._store[session_id] = {
73
+ **(data or {}),
74
+ "_created_at": time.time(),
75
+ "_last_accessed": time.time(),
76
+ }
77
+ return session_id
78
+
79
+ async def get(self, session_id: str) -> dict[str, Any] | None:
80
+ session = self._store.get(session_id)
81
+ if session is None:
82
+ return None
83
+ if self._is_expired(session):
84
+ del self._store[session_id]
85
+ return None
86
+ session["_last_accessed"] = time.time()
87
+ return {k: v for k, v in session.items() if not k.startswith("_")}
88
+
89
+ async def update(self, session_id: str, data: dict[str, Any]) -> bool:
90
+ if session_id not in self._store or self._is_expired(self._store[session_id]):
91
+ return False
92
+ self._store[session_id].update(data)
93
+ self._store[session_id]["_last_accessed"] = time.time()
94
+ return True
95
+
96
+ async def delete(self, session_id: str) -> bool:
97
+ if session_id in self._store:
98
+ del self._store[session_id]
99
+ return True
100
+ return False
101
+
102
+ async def exists(self, session_id: str) -> bool:
103
+ session = self._store.get(session_id)
104
+ if session is None:
105
+ return False
106
+ if self._is_expired(session):
107
+ del self._store[session_id]
108
+ return False
109
+ return True
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "npc-protocol"
7
+ version = "0.1.0"
8
+ description = "Python SDK for building NPC Protocol servers"
9
+ readme = "README.md"
10
+ license = { text = "Apache-2.0" }
11
+ requires-python = ">=3.9"
12
+ keywords = ["npc", "mcp", "agent", "protocol", "ai"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Software Development :: Libraries",
22
+ ]
23
+ dependencies = [
24
+ "mcp>=1.0.0",
25
+ "pydantic>=2.0.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ redis = ["redis>=5.0.0"]
30
+ dev = [
31
+ "pytest>=8.0.0",
32
+ "pytest-asyncio>=0.23.0",
33
+ "ruff>=0.4.0",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/macropulse/npc-protocol"
38
+ Documentation = "https://github.com/macropulse/npc-protocol/tree/main/spec"
39
+ Repository = "https://github.com/macropulse/npc-protocol"
40
+ Issues = "https://github.com/macropulse/npc-protocol/issues"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["npc"]
44
+
45
+ [tool.ruff]
46
+ line-length = 100
47
+ target-version = "py310"
48
+
49
+ [tool.pytest.ini_options]
50
+ asyncio_mode = "auto"