publicmcp 0.2.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,128 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship made available under
36
+ the License, as indicated by a copyright notice that is included in
37
+ or attached to the work (an example is provided in the Appendix below).
38
+
39
+ "Derivative Works" shall mean any work, whether in Source or Object
40
+ form, that is based on (or derived from) the Work and for which the
41
+ editorial revisions, annotations, elaborations, or other modifications
42
+ represent, as a whole, an original work of authorship. For the purposes
43
+ of this License, Derivative Works shall not include works that remain
44
+ separable from, or merely link (or bind by name) to the interfaces of,
45
+ the Work and Derivative Works thereof.
46
+
47
+ "Contribution" shall mean, as submitted to the Licensor for inclusion
48
+ in the Work by the copyright owner or by an individual or Legal Entity
49
+ authorized to submit on behalf of the copyright owner.
50
+
51
+ "Contributor" shall mean Licensor and any Legal Entity on behalf of
52
+ whom a Contribution has been received by the Licensor and included
53
+ within the Work.
54
+
55
+ 2. Grant of Copyright License. Subject to the terms and conditions of
56
+ this License, each Contributor hereby grants to You a perpetual,
57
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
58
+ copyright license to reproduce, prepare Derivative Works of,
59
+ publicly display, publicly perform, sublicense, and distribute the
60
+ Work and such Derivative Works in Source or Object form.
61
+
62
+ 3. Grant of Patent License. Subject to the terms and conditions of
63
+ this License, each Contributor hereby grants to You a perpetual,
64
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
65
+ (except as stated in this section) patent license to make, have made,
66
+ use, offer to sell, sell, import, and otherwise transfer the Work.
67
+
68
+ 4. Redistribution. You may reproduce and distribute copies of the
69
+ Work or Derivative Works thereof in any medium, with or without
70
+ modifications, and in Source or Object form, provided that You
71
+ meet the following conditions:
72
+
73
+ (a) You must give any other recipients of the Work or Derivative
74
+ Works a copy of this License; and
75
+
76
+ (b) You must cause any modified files to carry prominent notices
77
+ stating that You changed the files; and
78
+
79
+ (c) You must retain, in the Source form of any Derivative Works
80
+ that You distribute, all copyright, patent, trademark, and
81
+ attribution notices from the Source form of the Work; and
82
+
83
+ (d) If the Work includes a "NOTICE" text file, you must include a
84
+ readable copy of the attribution notices contained within such
85
+ NOTICE file, in at least one of the following places: within a
86
+ NOTICE text provided with the Derivative Works; within the Source
87
+ form or documentation, if provided along with the Derivative Works;
88
+ or, within a display generated by the Derivative Works, if and
89
+ wherever such third-party notices normally appear.
90
+
91
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
92
+ any Contribution intentionally submitted for inclusion in the Work
93
+ by You to the Licensor shall be under the terms and conditions of
94
+ this License, without any additional terms or conditions.
95
+
96
+ 6. Trademarks. This License does not grant permission to use the trade
97
+ names, trademarks, service marks, or product names of the Licensor,
98
+ except as required for reasonable and customary use in describing the
99
+ origin of the Work and reproducing the content of the NOTICE file.
100
+
101
+ 7. Disclaimer of Warranty. Unless required by applicable law or agreed
102
+ to in writing, Licensor provides the Work (and each Contributor
103
+ provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES
104
+ OR CONDITIONS OF ANY KIND, either express or implied.
105
+
106
+ 8. Limitation of Liability. In no event and under no legal theory,
107
+ whether in tort (including negligence), contract, or otherwise,
108
+ unless required by applicable law, shall any Contributor be liable
109
+ to You for damages, including any direct, indirect, special,
110
+ incidental, or exemplary damages of any character arising as a result
111
+ of this License or out of the use or inability to use the Work.
112
+
113
+ 9. Accepting Warranty or Liability. While redistributing the Work or
114
+ Derivative Works thereof, You may choose to offer, and charge a fee
115
+ for, acceptance of support, warranty, indemnity, or other liability
116
+ obligations and/or rights consistent with this License. However, in
117
+ accepting such obligations, You may offer such obligations only on
118
+ Your own behalf and on a sole responsibility basis.
119
+
120
+ END OF TERMS AND CONDITIONS
121
+
122
+ Copyright 2026 Joseph Provence / Salt Creative LLC
123
+
124
+ Licensed under the Apache License, Version 2.0 (the "License");
125
+ you may not use this file except in compliance with the License.
126
+ You may obtain a copy of the License at
127
+
128
+ http://www.apache.org/licenses/LICENSE-2.0
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: publicmcp
3
+ Version: 0.2.0
4
+ Summary: Build a PUBLICMCP-compliant business identity MCP server in minutes
5
+ Project-URL: Homepage, https://publicmcp.org
6
+ Project-URL: Repository, https://github.com/joeprovence/publicmcp
7
+ Project-URL: Spec, https://github.com/joeprovence/publicmcp/blob/main/SPEC.md
8
+ License: Apache-2.0
9
+ License-File: LICENSE
10
+ Keywords: agents,ai,business,fastmcp,identity,mcp
11
+ Classifier: Development Status :: 4 - Beta
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.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: click>=8.0
23
+ Requires-Dist: fastmcp>=3.2
24
+ Requires-Dist: starlette>=0.40
25
+ Requires-Dist: uvicorn>=0.30
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,199 @@
1
+ """
2
+ publicmcp/cli.py — CLI entry point.
3
+
4
+ Commands:
5
+ publicmcp init — create a starter business.json
6
+ publicmcp serve — start the PUBLICMCP server
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import shutil
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import click
16
+ import uvicorn
17
+
18
+ from publicmcp import __version__
19
+
20
+
21
+ @click.group()
22
+ @click.version_option(version=__version__, prog_name="publicmcp")
23
+ def cli():
24
+ """PUBLICMCP — business identity MCP server.
25
+
26
+ Build a spec-compliant MCP server for your business in minutes.
27
+
28
+ \b
29
+ Quickstart:
30
+ publicmcp init # create business.json
31
+ publicmcp serve # start server on :8000
32
+
33
+ Spec: https://github.com/joeprovence/publicmcp/blob/main/SPEC.md
34
+ """
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # publicmcp init
39
+ # ---------------------------------------------------------------------------
40
+
41
+ @cli.command()
42
+ @click.option(
43
+ "--output", "-o",
44
+ default="business.json",
45
+ show_default=True,
46
+ help="Output path for the business.json template.",
47
+ )
48
+ @click.option(
49
+ "--force", "-f",
50
+ is_flag=True,
51
+ default=False,
52
+ help="Overwrite existing file without prompting.",
53
+ )
54
+ def init(output: str, force: bool) -> None:
55
+ """Create a starter business.json template.
56
+
57
+ Generates a spec-compliant business.json with placeholder values.
58
+ Edit the file, then run 'publicmcp serve' to start your server.
59
+ """
60
+ output_path = Path(output)
61
+
62
+ if output_path.exists() and not force:
63
+ click.confirm(
64
+ f"{output_path} already exists. Overwrite?",
65
+ abort=True,
66
+ )
67
+
68
+ template_path = Path(__file__).parent / "templates" / "business.json"
69
+ shutil.copy(template_path, output_path)
70
+
71
+ click.echo(f"Created {output_path}")
72
+ click.echo("")
73
+ click.echo("Next steps:")
74
+ click.echo(f" 1. Edit {output_path} with your business details")
75
+ click.echo(" 2. Run: publicmcp serve")
76
+ click.echo(" 3. Connect your AI client to: http://localhost:8000/mcp")
77
+ click.echo("")
78
+ click.echo("Spec: https://github.com/joeprovence/publicmcp/blob/main/SPEC.md")
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # publicmcp serve
83
+ # ---------------------------------------------------------------------------
84
+
85
+ @cli.command()
86
+ @click.option(
87
+ "--business", "-b",
88
+ default="business.json",
89
+ show_default=True,
90
+ help="Path to your business.json file.",
91
+ )
92
+ @click.option(
93
+ "--host",
94
+ default="0.0.0.0",
95
+ show_default=True,
96
+ help="Host to bind to.",
97
+ )
98
+ @click.option(
99
+ "--port", "-p",
100
+ default=8000,
101
+ show_default=True,
102
+ type=int,
103
+ help="Port to listen on.",
104
+ )
105
+ @click.option(
106
+ "--domain",
107
+ default="",
108
+ help=(
109
+ "Public domain name (e.g. mcp.yourdomain.com). "
110
+ "Used in the discovery endpoint URL. "
111
+ "Defaults to the domain in your business.json website field."
112
+ ),
113
+ )
114
+ @click.option(
115
+ "--reload",
116
+ is_flag=True,
117
+ default=False,
118
+ help="Enable auto-reload on business.json changes (dev mode).",
119
+ )
120
+ def serve(
121
+ business: str,
122
+ host: str,
123
+ port: int,
124
+ domain: str,
125
+ reload: bool,
126
+ ) -> None:
127
+ """Start the PUBLICMCP server.
128
+
129
+ Reads business.json and starts a FastMCP server with 6 tools and 1 prompt.
130
+ Browsers are redirected to your publicmcp_page landing. AI agents get MCP.
131
+
132
+ \b
133
+ Endpoints:
134
+ /mcp — MCP (streamable-http)
135
+ /.well-known/publicmcp.json — discovery
136
+ /info — plain-text summary
137
+ """
138
+ business_path = Path(business)
139
+
140
+ if not business_path.is_file():
141
+ click.echo(
142
+ f"Error: {business_path} not found.\n"
143
+ "Run 'publicmcp init' to create a starter business.json.",
144
+ err=True,
145
+ )
146
+ sys.exit(1)
147
+
148
+ # Validate JSON before starting
149
+ try:
150
+ with open(business_path) as f:
151
+ biz = json.load(f)
152
+ except json.JSONDecodeError as exc:
153
+ click.echo(f"Error: Invalid JSON in {business_path}: {exc}", err=True)
154
+ sys.exit(1)
155
+
156
+ # Resolve domain: CLI flag > website field > localhost
157
+ if not domain:
158
+ website = biz.get("website", "")
159
+ domain = (
160
+ website
161
+ .removeprefix("https://")
162
+ .removeprefix("http://")
163
+ .removeprefix("www.")
164
+ .rstrip("/")
165
+ or "localhost"
166
+ )
167
+
168
+ # Spec compliance pre-check
169
+ missing = [
170
+ field for field in ("name", "services", "locations")
171
+ if not biz.get(field)
172
+ ]
173
+ if missing:
174
+ click.echo(
175
+ f"Warning: business.json is missing required fields: {', '.join(missing)}\n"
176
+ "Server will start but may not pass spec compliance.",
177
+ err=True,
178
+ )
179
+
180
+ # Import here to keep startup fast
181
+ from publicmcp.server import build_app
182
+
183
+ app = build_app(business_path=business_path, domain=domain)
184
+
185
+ click.echo(f"PUBLICMCP v{__version__} — {biz.get('name', 'unnamed')}")
186
+ click.echo(f" MCP endpoint : http://{host}:{port}/mcp")
187
+ click.echo(f" Discovery : http://{host}:{port}/.well-known/publicmcp.json")
188
+ click.echo(f" Info : http://{host}:{port}/info")
189
+ click.echo(f" Business : {business_path.resolve()}")
190
+ click.echo(f" Domain : {domain}")
191
+ click.echo("")
192
+
193
+ uvicorn.run(
194
+ app,
195
+ host=host,
196
+ port=port,
197
+ reload=reload,
198
+ log_level="info",
199
+ )
@@ -0,0 +1,721 @@
1
+ """
2
+ publicmcp/server.py — PUBLICMCP v0.2 single-tenant FastMCP server.
3
+
4
+ Reads one business.json file and exposes 6 tools + 1 prompt over MCP.
5
+ Configured via build_app(business_path, domain) before calling uvicorn.run().
6
+
7
+ Spec: https://github.com/joeprovence/publicmcp/blob/main/SPEC.md
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from fastmcp import FastMCP
15
+ from starlette.applications import Starlette
16
+ from starlette.middleware.base import BaseHTTPMiddleware
17
+ from starlette.requests import Request
18
+ from starlette.responses import JSONResponse, PlainTextResponse, RedirectResponse
19
+ from starlette.routing import Mount, Route
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Module-level config — set once by build_app() before uvicorn starts.
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _business_path: Path = Path("business.json")
27
+ _domain: str = "localhost"
28
+
29
+ _BROWSER_TOKENS = ("mozilla", "chrome", "safari", "firefox", "msie", "trident", "edge")
30
+ _PASSTHROUGH_PATHS = frozenset(["/.well-known/publicmcp.json", "/", "/info"])
31
+
32
+ _VERTICAL_KEYWORDS: dict[str, list[str]] = {
33
+ "functional_medicine": [
34
+ "functional medicine", "integrative medicine", "naturopath",
35
+ "holistic", "acupuncture", "wellness clinic",
36
+ ],
37
+ "therapy_practices": [
38
+ "therapy", "therapist", "counseling", "counselor",
39
+ "mental health", "psychologist", "psychiatrist", "behavioral health",
40
+ ],
41
+ "vacation_rentals": [
42
+ "vacation rental", "airbnb", "vrbo", "short term rental",
43
+ "short-term rental", "cabin rental", "lodge", "retreat center",
44
+ ],
45
+ "civil_engineering": [
46
+ "civil engineering", "engineering firm", "infrastructure",
47
+ "municipal services", "surveying",
48
+ ],
49
+ "real_estate": [
50
+ "real estate", "realtor", "realty", "real estate agent",
51
+ "real estate broker", "property management", "property manager",
52
+ ],
53
+ "healthcare": [
54
+ "healthcare", "medical", "clinic", "hospital", "doctor", "dental",
55
+ "dentist", "physician", "pediatric", "chiropractic", "optometry",
56
+ "urgent care", "family medicine",
57
+ ],
58
+ "construction": [
59
+ "construction", "contractor", "general contractor", "builder",
60
+ "plumber", "plumbing", "electrician", "electrical", "hvac",
61
+ "roofing", "concrete", "masonry", "landscaping", "excavation",
62
+ "remodeling", "home improvement",
63
+ ],
64
+ "ecommerce": [
65
+ "ecommerce", "e-commerce", "online store", "shopify",
66
+ "retail", "boutique", "product shop",
67
+ ],
68
+ }
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Business data loader
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def _load_business() -> dict:
76
+ try:
77
+ with open(_business_path) as f:
78
+ return json.load(f)
79
+ except FileNotFoundError:
80
+ raise RuntimeError(
81
+ f"business.json not found at: {_business_path}\n"
82
+ "Run 'publicmcp init' to create one, or pass --business <path>."
83
+ ) from None
84
+ except json.JSONDecodeError as exc:
85
+ raise RuntimeError(f"Invalid JSON in {_business_path}: {exc}") from exc
86
+
87
+
88
+ def _extract_city(market_str: str) -> str:
89
+ parts = market_str.split()
90
+ return " ".join(parts[:-1]).lower()
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Browser redirect middleware — browsers go to the publicmcp_page landing
95
+ # ---------------------------------------------------------------------------
96
+
97
+ class _BrowserRedirectMiddleware(BaseHTTPMiddleware):
98
+ async def dispatch(self, request: Request, call_next):
99
+ ua = request.headers.get("user-agent", "").lower()
100
+ accept = request.headers.get("accept", "").lower()
101
+ is_browser = any(token in ua for token in _BROWSER_TOKENS)
102
+ wants_json = "application/json" in accept
103
+ in_passthrough = request.url.path in _PASSTHROUGH_PATHS
104
+ if is_browser and not wants_json and not in_passthrough:
105
+ biz = _load_business()
106
+ landing = biz.get(
107
+ "publicmcp_page",
108
+ f"https://www.{_domain.removeprefix('mcp.')}",
109
+ )
110
+ return RedirectResponse(landing, status_code=301)
111
+ return await call_next(request)
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # FastMCP instance
116
+ # ---------------------------------------------------------------------------
117
+
118
+ mcp = FastMCP("publicmcp-server")
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Prompt: sales_workflow
123
+ # ---------------------------------------------------------------------------
124
+
125
+ @mcp.prompt()
126
+ def sales_workflow() -> str:
127
+ """
128
+ Workflow guide for qualifying a prospect using this PUBLICMCP server.
129
+ Load at session start to ensure correct tool chaining order.
130
+ """
131
+ return """\
132
+ You are a consultative sales assistant connected to a PUBLICMCP-compliant server.
133
+
134
+ ## Tool Chaining Order
135
+
136
+ 1. get_info()
137
+ Confirm you are connected to the correct business before calling anything else.
138
+
139
+ 2. qualify_prospect(description="<prospect description>")
140
+ Pass a brief description including industry, location, and current website
141
+ status if known. Returns fit_score, detected_vertical, recommended_service,
142
+ and discovery questions. Start here for prospect qualification.
143
+ Note: only available on servers with primary_markets data configured.
144
+
145
+ 3. get_sales_context(vertical="<detected_vertical>")
146
+ Call after qualify_prospect when fit_score is "strong" or "possible".
147
+ Returns full objection handling, pain points, and ideal client profile.
148
+ Note: only available on servers with sales_agent data configured.
149
+
150
+ 4. get_services(category="design" or "marketing")
151
+ Call when the prospect asks about specific services or pricing.
152
+ Never fabricate pricing — only quote what this tool returns.
153
+
154
+ 5. get_location(market="<city>")
155
+ Confirm the business serves the prospect's market if location is ambiguous.
156
+
157
+ 6. get_portfolio(market="<city>", category="<type>")
158
+ Use when the prospect wants to see relevant past work.
159
+
160
+ ## Rules
161
+
162
+ - Always confirm location early — not all businesses serve all markets.
163
+ - If qualify_prospect returns fit_score "poor", ask clarifying questions.
164
+ - Never invent services, pricing, or portfolio items not returned by tools.
165
+ """
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Tool: get_info
170
+ # ---------------------------------------------------------------------------
171
+
172
+ @mcp.tool()
173
+ def get_info() -> str:
174
+ """
175
+ Return core identity information about this business (PUBLICMCP v0.2).
176
+
177
+ Includes business name, tagline, description, founding year, website,
178
+ business type, legal name, logo, price range, contact details,
179
+ structured opening hours, and social profiles.
180
+
181
+ Returns:
182
+ JSON object conforming to PUBLICMCP v0.2 get_info schema.
183
+ """
184
+ biz = _load_business()
185
+
186
+ raw_contact = biz.get("contact", {})
187
+ contact = {k: v for k, v in raw_contact.items() if k != "phone"}
188
+ if "phone" in raw_contact and "telephone" not in contact:
189
+ contact["telephone"] = raw_contact["phone"]
190
+
191
+ social_profiles = biz.get("social_profiles") or biz.get("social", {})
192
+
193
+ return json.dumps(
194
+ {
195
+ "publicmcp_version": "0.2",
196
+ "name": biz.get("name", ""),
197
+ "tagline": biz.get("tagline", ""),
198
+ "description": biz.get("description", ""),
199
+ "founded": biz.get("founded"),
200
+ "founder": biz.get("founder", ""),
201
+ "co_founder": biz.get("co_founder", ""),
202
+ "website": biz.get("website", ""),
203
+ "business_type": biz.get("business_type", []),
204
+ "legal_name": biz.get("legal_name", ""),
205
+ "logo": biz.get("logo", ""),
206
+ "price_range": biz.get("price_range", ""),
207
+ "contact": contact,
208
+ "opening_hours": biz.get("opening_hours", []),
209
+ "social_profiles": social_profiles,
210
+ },
211
+ indent=2,
212
+ )
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Tool: get_services
217
+ # ---------------------------------------------------------------------------
218
+
219
+ @mcp.tool()
220
+ def get_services(category: str = "") -> str:
221
+ """
222
+ Return services offered by this business.
223
+
224
+ Args:
225
+ category: Optional filter. Leave empty to return all services.
226
+ Common values: 'design', 'marketing', 'hair', 'color',
227
+ 'styling', 'extensions' — depends on the business.
228
+
229
+ Returns:
230
+ JSON array of service objects with name, category, description,
231
+ highlights, pricing, and timeline.
232
+ """
233
+ biz = _load_business()
234
+ services = biz.get("services", [])
235
+
236
+ if category:
237
+ services = [s for s in services if s.get("category", "") == category.lower()]
238
+
239
+ if not services:
240
+ return json.dumps({"error": f"No services found for category: {category!r}"})
241
+
242
+ return json.dumps(services, indent=2)
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Tool: get_location
247
+ # ---------------------------------------------------------------------------
248
+
249
+ @mcp.tool()
250
+ def get_location(market: str = "") -> str:
251
+ """
252
+ Return geographic presence and service coverage (PUBLICMCP v0.2).
253
+
254
+ Includes headquarters, geo coordinates, service radius, remote capability,
255
+ office locations, and full service area list.
256
+
257
+ Args:
258
+ market: Optional city filter (e.g. 'Boise', 'Spokane').
259
+ Leave empty to return all offices and service areas.
260
+
261
+ Returns:
262
+ JSON object conforming to PUBLICMCP v0.2 get_location schema.
263
+ """
264
+ biz = _load_business()
265
+ locations = biz.get("locations", [])
266
+ service_areas = biz.get("service_areas", [])
267
+
268
+ if market:
269
+ locations = [
270
+ loc for loc in locations
271
+ if (
272
+ market.lower() in loc.get("city", "").lower()
273
+ or market.lower() in loc.get("market", "").lower()
274
+ )
275
+ ]
276
+
277
+ headquarters = biz.get("headquarters")
278
+ if not headquarters:
279
+ primary = next(
280
+ (loc for loc in biz.get("locations", []) if loc.get("primary")),
281
+ biz.get("locations", [{}])[0] if biz.get("locations") else {},
282
+ )
283
+ city = primary.get("city", "")
284
+ state = primary.get("state", "")
285
+ headquarters = f"{city}, {state}".strip(", ") if city or state else ""
286
+
287
+ return json.dumps(
288
+ {
289
+ "headquarters": headquarters,
290
+ "geo": biz.get("geo"),
291
+ "geo_radius": biz.get("geo_radius"),
292
+ "remote_capable": biz.get("remote_capable", True),
293
+ "offices": locations,
294
+ "service_areas": service_areas,
295
+ },
296
+ indent=2,
297
+ )
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # Tool: get_portfolio
302
+ # ---------------------------------------------------------------------------
303
+
304
+ @mcp.tool()
305
+ def get_portfolio(market: str = "", category: str = "") -> str:
306
+ """
307
+ Return portfolio highlights from this business.
308
+
309
+ Args:
310
+ market: Optional market filter (e.g. 'Boise', 'Meridian').
311
+ Leave empty for all markets.
312
+ category: Optional category filter (e.g. 'web design', 'color').
313
+ Leave empty for all categories.
314
+
315
+ Returns:
316
+ JSON array of portfolio items with title, description, category,
317
+ and markets list.
318
+ """
319
+ biz = _load_business()
320
+ items = biz.get("portfolio", [])
321
+
322
+ if market:
323
+ items = [
324
+ item for item in items
325
+ if any(market.lower() in m.lower() for m in item.get("markets", []))
326
+ ]
327
+
328
+ if category:
329
+ items = [
330
+ item for item in items
331
+ if category.lower() in item.get("category", "").lower()
332
+ ]
333
+
334
+ if not items:
335
+ return json.dumps({"message": "No portfolio items match those filters."})
336
+
337
+ return json.dumps(items, indent=2)
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # Tool: get_sales_context (managed-service extension)
342
+ # ---------------------------------------------------------------------------
343
+
344
+ @mcp.tool()
345
+ def get_sales_context(vertical: str = "") -> str:
346
+ """
347
+ Return consultative sales context for a specific business vertical.
348
+
349
+ Use this after qualify_prospect returns a detected_vertical. Returns pain
350
+ points, objection handling, competitive positioning, ideal client profile,
351
+ qualifying questions, and social proof for that vertical.
352
+
353
+ Only available on servers with sales_agent data configured in business.json.
354
+
355
+ Args:
356
+ vertical: The prospect's industry vertical. Common values: real_estate,
357
+ healthcare, construction, functional_medicine, vacation_rentals,
358
+ ecommerce, therapy_practices, civil_engineering.
359
+ Leave empty for general context.
360
+
361
+ Returns:
362
+ JSON object with pain_point, objection_responses,
363
+ competitive_positioning, ideal_client_profile, discovery_questions,
364
+ qualifying_questions, and social_proof.
365
+ """
366
+ biz = _load_business()
367
+ agent = biz.get("sales_agent")
368
+
369
+ if not agent:
370
+ return json.dumps({
371
+ "message": (
372
+ "Sales context is not configured for this server. "
373
+ "Add a 'sales_agent' block to your business.json to enable it."
374
+ )
375
+ })
376
+
377
+ profiles = biz.get("ideal_client_profiles", [])
378
+ qualifying = biz.get("qualifying_questions", {})
379
+
380
+ vertical_key = (
381
+ vertical.lower().replace(" ", "_").replace("-", "_") if vertical else ""
382
+ )
383
+
384
+ pain_point = None
385
+ profile = None
386
+
387
+ if vertical_key:
388
+ pain_point = agent.get("pain_points_by_vertical", {}).get(vertical_key)
389
+ profile = next(
390
+ (
391
+ p for p in profiles
392
+ if vertical_key in p["persona"].lower().replace(" ", "_")
393
+ or any(
394
+ vertical_key in e.lower().replace(" ", "_")
395
+ for e in p.get("examples", [])
396
+ )
397
+ ),
398
+ None,
399
+ )
400
+
401
+ result: dict = {}
402
+
403
+ if pain_point:
404
+ result["pain_point"] = pain_point
405
+ elif vertical_key:
406
+ result["pain_point"] = (
407
+ f"No specific pain point data for '{vertical}'. "
408
+ "Use general positioning from competitive_positioning below."
409
+ )
410
+
411
+ result["objection_responses"] = agent.get("objections", {})
412
+ result["competitive_positioning"] = agent.get("competitive_positioning", {})
413
+ result["social_proof"] = agent.get("social_proof", {})
414
+ result["discovery_questions"] = agent.get("discovery_questions", [])
415
+ result["qualifying_questions"] = qualifying.get("questions", [])
416
+
417
+ if profile:
418
+ result["ideal_client_profile"] = profile
419
+ else:
420
+ result["ideal_client_profiles"] = profiles
421
+
422
+ result["project_fit"] = biz.get("project_fit", {})
423
+
424
+ return json.dumps(result, indent=2)
425
+
426
+
427
+ # ---------------------------------------------------------------------------
428
+ # Tool: qualify_prospect (managed-service extension)
429
+ # ---------------------------------------------------------------------------
430
+
431
+ @mcp.tool()
432
+ def qualify_prospect(description: str) -> str:
433
+ """
434
+ Assess whether a prospect is a good fit for this business.
435
+
436
+ Analyzes a free-text prospect description to detect vertical, location,
437
+ and intent signals. Returns a structured fit assessment with recommended
438
+ service and discovery questions.
439
+
440
+ Only available on servers with primary_markets data in business.json.
441
+
442
+ Args:
443
+ description: Free-text description of the prospect. Include location,
444
+ industry, current situation, and goals if known.
445
+ Example: "pediatric dentist in Spokane, currently on Wix,
446
+ 4-person practice, wants more new patient leads".
447
+
448
+ Returns:
449
+ JSON object with fit_score (strong/possible/poor), fit_reason,
450
+ detected_vertical, location_match, matched_market,
451
+ recommended_service, pricing_hint, discovery_questions,
452
+ vertical_pain_point (if available), and next_step.
453
+ """
454
+ if not description or not description.strip():
455
+ return json.dumps({"error": "description is required."})
456
+
457
+ biz = _load_business()
458
+ primary_markets: list = biz.get("primary_markets") or []
459
+
460
+ if not primary_markets:
461
+ return json.dumps({
462
+ "message": (
463
+ "qualify_prospect requires 'primary_markets' in business.json. "
464
+ "Add a list of your target markets to enable this tool."
465
+ )
466
+ })
467
+
468
+ desc = description.lower()
469
+ service_areas: list = biz.get("service_areas") or []
470
+
471
+ detected_vertical = None
472
+ for vertical, keywords in _VERTICAL_KEYWORDS.items():
473
+ if any(kw in desc for kw in keywords):
474
+ detected_vertical = vertical
475
+ break
476
+
477
+ location_match = "out_of_market"
478
+ matched_market = None
479
+
480
+ for market in primary_markets:
481
+ if _extract_city(market) in desc:
482
+ location_match = "primary_market"
483
+ matched_market = market
484
+ break
485
+
486
+ if location_match == "out_of_market":
487
+ for area in service_areas:
488
+ city = _extract_city(area)
489
+ if len(city) > 3 and city in desc:
490
+ location_match = "service_area"
491
+ matched_market = area
492
+ break
493
+
494
+ has_no_website = any(s in desc for s in [
495
+ "no website", "don't have a website", "dont have a website",
496
+ "need a website", "starting from scratch", "never had a website",
497
+ ])
498
+ needs_redesign = any(s in desc for s in [
499
+ "redesign", "outdated", "old website", "rebuild", "wix",
500
+ "squarespace", "godaddy website", "diy site", "not working",
501
+ "needs work", "looks bad",
502
+ ])
503
+ wants_seo = any(s in desc for s in [
504
+ "seo", "ranking", "rank on google", "google search",
505
+ "organic traffic", "search traffic", "show up on google", "leads",
506
+ ])
507
+
508
+ in_service_area = location_match in ("primary_market", "service_area")
509
+ has_vertical = detected_vertical is not None
510
+
511
+ if in_service_area and has_vertical:
512
+ fit_score = "strong"
513
+ fit_reason = (
514
+ f"Served market ({matched_market}) with clear vertical match "
515
+ f"({detected_vertical.replace('_', ' ')})."
516
+ )
517
+ elif in_service_area:
518
+ fit_score = "possible"
519
+ fit_reason = (
520
+ f"Served market ({matched_market}) confirmed but industry vertical "
521
+ "is unclear — ask about their business type."
522
+ )
523
+ elif has_vertical:
524
+ fit_score = "possible"
525
+ fit_reason = (
526
+ f"Vertical match ({detected_vertical.replace('_', ' ')}) detected "
527
+ "but location not confirmed in service area — verify their market."
528
+ )
529
+ else:
530
+ fit_score = "poor"
531
+ fit_reason = (
532
+ "Neither location nor vertical could be confirmed from the "
533
+ "description. More detail needed before routing to a quote."
534
+ )
535
+
536
+ services = biz.get("services", [])
537
+ default_service = services[0]["name"] if services else "Core Service"
538
+
539
+ if has_no_website:
540
+ recommended_service = f"{default_service} — New Build"
541
+ pricing_hint = "Contact for quote"
542
+ elif needs_redesign and wants_seo:
543
+ recommended_service = f"{default_service} + SEO / Growth Package"
544
+ pricing_hint = "Contact for quote"
545
+ elif needs_redesign:
546
+ recommended_service = f"{default_service} — Redesign"
547
+ pricing_hint = "Contact for quote"
548
+ elif wants_seo:
549
+ recommended_service = "SEO / Growth Service"
550
+ pricing_hint = "Contact for quote"
551
+ else:
552
+ recommended_service = default_service
553
+ pricing_hint = "Contact for quote"
554
+
555
+ if services:
556
+ pricing = services[0].get("pricing", {})
557
+ if isinstance(pricing, dict) and pricing.get("project_range"):
558
+ pricing_hint = pricing["project_range"]
559
+
560
+ questions = biz.get("qualifying_questions", {}).get("questions", [])
561
+ discovery_questions = [q["question"] for q in questions[:3]]
562
+
563
+ contact_url = biz.get("contact", {}).get("quote_url") or biz.get("website", "")
564
+ if fit_score == "strong":
565
+ next_step = f"Route to a project quote: {contact_url}"
566
+ elif fit_score == "possible":
567
+ next_step = (
568
+ "Gather more details on location and goals, then route to a quote "
569
+ "if confirmed in service area."
570
+ )
571
+ else:
572
+ next_step = (
573
+ "Clarify location and industry before proceeding. "
574
+ "Call get_location() to review service areas."
575
+ )
576
+
577
+ result: dict = {
578
+ "fit_score": fit_score,
579
+ "fit_reason": fit_reason,
580
+ "detected_vertical": (
581
+ detected_vertical
582
+ or "unknown — include industry in description for better results"
583
+ ),
584
+ "location_match": location_match,
585
+ "matched_market": matched_market,
586
+ "recommended_service": recommended_service,
587
+ "pricing_hint": pricing_hint,
588
+ "discovery_questions": discovery_questions,
589
+ "next_step": next_step,
590
+ }
591
+
592
+ if detected_vertical:
593
+ pain_point = (
594
+ biz.get("sales_agent", {})
595
+ .get("pain_points_by_vertical", {})
596
+ .get(detected_vertical)
597
+ )
598
+ if pain_point:
599
+ result["vertical_pain_point"] = pain_point
600
+
601
+ return json.dumps(result, indent=2)
602
+
603
+
604
+ # ---------------------------------------------------------------------------
605
+ # Static endpoints
606
+ # ---------------------------------------------------------------------------
607
+
608
+ async def _info_handler(request: Request) -> PlainTextResponse:
609
+ biz = _load_business()
610
+ services_list = biz.get("services", [])
611
+ service_names = ", ".join(s["name"] for s in services_list[:4])
612
+ text = (
613
+ f"Business: {biz.get('name')}\n"
614
+ f"Tagline: {biz.get('tagline')}\n"
615
+ f"Website: {biz.get('website')}\n"
616
+ f"\nServices: {service_names}\n"
617
+ f"\nTools: get_info, get_services, get_location, get_portfolio, "
618
+ f"get_sales_context, qualify_prospect\n"
619
+ f"Prompt: sales_workflow\n"
620
+ f"\nMCP endpoint: https://{_domain}/mcp\n"
621
+ f"Discovery: https://{_domain}/.well-known/publicmcp.json\n"
622
+ f"Spec: https://github.com/joeprovence/publicmcp/blob/main/SPEC.md\n"
623
+ )
624
+ return PlainTextResponse(text, headers={"Access-Control-Allow-Origin": "*"})
625
+
626
+
627
+ def _discovery_payload() -> dict:
628
+ biz = _load_business()
629
+ has_sales = bool(biz.get("sales_agent"))
630
+ has_markets = bool(biz.get("primary_markets"))
631
+
632
+ tools_list = [
633
+ {"name": "get_info", "version": "0.2", "required": True},
634
+ {"name": "get_services", "version": "0.2", "required": True},
635
+ {"name": "get_location", "version": "0.2", "required": True},
636
+ {"name": "get_portfolio", "version": "0.2", "required": False},
637
+ ]
638
+ if has_sales:
639
+ tools_list.append({"name": "get_sales_context", "version": "0.2", "required": False})
640
+ if has_markets:
641
+ tools_list.append({"name": "qualify_prospect", "version": "0.2", "required": False})
642
+
643
+ tool_guide = {
644
+ "get_info": "Core identity — name, description, founders, contact",
645
+ "get_services": "Service list with pricing, timeline, and highlights",
646
+ "get_location": "Office locations and service areas",
647
+ "get_portfolio": "Portfolio highlights by market and category",
648
+ }
649
+ if has_sales:
650
+ tool_guide["get_sales_context"] = (
651
+ "Consultative sales brief by vertical — pain points, objections, "
652
+ "ideal client profile, qualifying questions"
653
+ )
654
+ if has_markets:
655
+ tool_guide["qualify_prospect"] = (
656
+ "Pass a free-text prospect description to receive fit_score, "
657
+ "detected_vertical, and recommended_service."
658
+ )
659
+
660
+ return {
661
+ "publicmcp_version": "0.2",
662
+ "auth": "none",
663
+ "note": (
664
+ "This is a Model Context Protocol (MCP) server that lets AI agents "
665
+ "query structured business data. Use the tools below to learn about "
666
+ f"{biz.get('name', 'this business')} and assess fit for a prospect."
667
+ ),
668
+ "name": f"{biz.get('name', _domain).lower().replace(' ', '-')}-publicmcp",
669
+ "business": f"{biz.get('name')} — {biz.get('tagline', '')}",
670
+ "endpoint": f"https://{_domain}/mcp",
671
+ "prompts": {
672
+ "sales_workflow": (
673
+ "Load at session start — defines correct tool chaining order "
674
+ "for prospect qualification."
675
+ ),
676
+ },
677
+ "tools": tools_list,
678
+ "tool_guide": tool_guide,
679
+ "spec": "https://github.com/joeprovence/publicmcp/blob/main/SPEC.md",
680
+ "registry": (
681
+ "https://github.com/joeprovence/publicmcp"
682
+ "/blob/main/registry/servers.yaml"
683
+ ),
684
+ }
685
+
686
+
687
+ _CORS_HEADERS = {
688
+ "Access-Control-Allow-Origin": "*",
689
+ "Access-Control-Allow-Methods": "GET",
690
+ }
691
+
692
+
693
+ async def _well_known_handler(request: Request) -> JSONResponse:
694
+ return JSONResponse(_discovery_payload(), headers=_CORS_HEADERS)
695
+
696
+
697
+ async def _root_handler(request: Request) -> JSONResponse:
698
+ return JSONResponse(_discovery_payload(), headers=_CORS_HEADERS)
699
+
700
+
701
+ # ---------------------------------------------------------------------------
702
+ # App factory — called by cli.py before uvicorn.run()
703
+ # ---------------------------------------------------------------------------
704
+
705
+ def build_app(business_path: Path, domain: str = "localhost") -> Starlette:
706
+ global _business_path, _domain
707
+ _business_path = Path(business_path).resolve()
708
+ _domain = domain
709
+
710
+ mcp_app = mcp.http_app(transport="streamable-http")
711
+ app = Starlette(
712
+ lifespan=mcp_app.lifespan,
713
+ routes=[
714
+ Route("/", _root_handler, methods=["GET"]),
715
+ Route("/info", _info_handler, methods=["GET"]),
716
+ Route("/.well-known/publicmcp.json", _well_known_handler, methods=["GET"]),
717
+ Mount("/", app=mcp_app),
718
+ ],
719
+ )
720
+ app.add_middleware(_BrowserRedirectMiddleware)
721
+ return app
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "Your Business Name",
3
+ "tagline": "Your tagline here",
4
+ "description": "A brief description of what your business does and who it serves.",
5
+ "founded": 2020,
6
+ "founder": "Founder Name",
7
+ "website": "https://www.yourdomain.com",
8
+ "publicmcp_page": "https://www.yourdomain.com/publicmcp",
9
+ "business_type": ["LocalBusiness"],
10
+ "legal_name": "Your Business LLC",
11
+ "logo": "https://www.yourdomain.com/logo.png",
12
+ "price_range": "$$",
13
+ "contact": {
14
+ "email": "hello@yourdomain.com",
15
+ "telephone": "",
16
+ "quote_url": "https://www.yourdomain.com/contact"
17
+ },
18
+ "opening_hours": [
19
+ {
20
+ "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
21
+ "opens": "09:00",
22
+ "closes": "17:00"
23
+ }
24
+ ],
25
+ "social_profiles": {
26
+ "twitter": "",
27
+ "facebook": "",
28
+ "linkedin": ""
29
+ },
30
+ "headquarters": "Your City, ST",
31
+ "geo": {
32
+ "lat": 0.0,
33
+ "lng": 0.0
34
+ },
35
+ "remote_capable": true,
36
+ "locations": [
37
+ {
38
+ "city": "Your City",
39
+ "state": "ST",
40
+ "market": "Your City ST",
41
+ "primary": true
42
+ }
43
+ ],
44
+ "service_areas": [
45
+ "Your City ST"
46
+ ],
47
+ "services": [
48
+ {
49
+ "name": "Your Primary Service",
50
+ "category": "primary",
51
+ "description": "Describe what you offer and who it is for.",
52
+ "highlights": [
53
+ "Key benefit 1",
54
+ "Key benefit 2",
55
+ "Key benefit 3"
56
+ ],
57
+ "pricing": {
58
+ "project_range": "Contact for quote"
59
+ },
60
+ "timeline": "2-4 weeks"
61
+ }
62
+ ],
63
+ "portfolio": [
64
+ {
65
+ "title": "Sample Project",
66
+ "description": "Brief description of a past project and its outcome.",
67
+ "category": "primary",
68
+ "markets": ["Your City ST"]
69
+ }
70
+ ],
71
+ "project_fit": {
72
+ "ideal_client": "Describe your ideal client here.",
73
+ "not_a_fit": "Describe who you do not serve here."
74
+ }
75
+ }
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "publicmcp"
7
+ version = "0.2.0"
8
+ description = "Build a PUBLICMCP-compliant business identity MCP server in minutes"
9
+ requires-python = ">=3.9"
10
+ license = { text = "Apache-2.0" }
11
+ keywords = ["mcp", "ai", "business", "identity", "agents", "fastmcp"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: Apache Software License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.9",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ ]
24
+ dependencies = [
25
+ "fastmcp>=3.2",
26
+ "uvicorn>=0.30",
27
+ "starlette>=0.40",
28
+ "click>=8.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://publicmcp.org"
33
+ Repository = "https://github.com/joeprovence/publicmcp"
34
+ Spec = "https://github.com/joeprovence/publicmcp/blob/main/SPEC.md"
35
+
36
+ [project.scripts]
37
+ publicmcp = "publicmcp.cli:cli"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["publicmcp"]