stupidhuman-func 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.
- stupidhuman_func-0.2.0/PKG-INFO +11 -0
- stupidhuman_func-0.2.0/README.md +584 -0
- stupidhuman_func-0.2.0/azure_func/__init__.py +5 -0
- stupidhuman_func-0.2.0/azure_func/app.py +48 -0
- stupidhuman_func-0.2.0/azure_func/decorator.py +234 -0
- stupidhuman_func-0.2.0/azure_func/mcp_server.py +183 -0
- stupidhuman_func-0.2.0/azure_func/openai.py +117 -0
- stupidhuman_func-0.2.0/azure_func/scope.py +66 -0
- stupidhuman_func-0.2.0/azure_func/service_handler.py +251 -0
- stupidhuman_func-0.2.0/pyproject.toml +19 -0
- stupidhuman_func-0.2.0/setup.cfg +4 -0
- stupidhuman_func-0.2.0/setup.py +17 -0
- stupidhuman_func-0.2.0/stupidhuman_func.egg-info/PKG-INFO +11 -0
- stupidhuman_func-0.2.0/stupidhuman_func.egg-info/SOURCES.txt +17 -0
- stupidhuman_func-0.2.0/stupidhuman_func.egg-info/dependency_links.txt +1 -0
- stupidhuman_func-0.2.0/stupidhuman_func.egg-info/requires.txt +6 -0
- stupidhuman_func-0.2.0/stupidhuman_func.egg-info/top_level.txt +1 -0
- stupidhuman_func-0.2.0/tests/test_decorator.py +540 -0
- stupidhuman_func-0.2.0/tests/test_mcp_server.py +320 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stupidhuman-func
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Decorator library for turning plain Python functions into Azure HTTP-triggered Functions
|
|
5
|
+
Requires-Python: >=3.8
|
|
6
|
+
Requires-Dist: azure-functions>=1.24.0
|
|
7
|
+
Requires-Dist: azurefunctions-extensions-http-fastapi
|
|
8
|
+
Requires-Dist: httpx>=0.24.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
11
|
+
Dynamic: requires-python
|
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
# azure-func-decorator
|
|
2
|
+
|
|
3
|
+
A decorator library that turns plain Python functions into Azure HTTP-triggered
|
|
4
|
+
Functions or MCP tool triggers automatically. It handles parameter extraction
|
|
5
|
+
(query string, route params, and JSON body), type coercion, OAuth2/JWT scope
|
|
6
|
+
validation, and error responses.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
**From GitHub via HTTPS:**
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install git+https://github.com/stupidhumanAI/python-func.git
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**From GitHub via SSH** (requires an SSH key configured for your GitHub account):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install git+ssh://git@github.com/stupidhumanAI/python-func.git
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Pin to a specific tag or commit when you need reproducible installs:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# HTTPS
|
|
26
|
+
pip install git+https://github.com/stupidhumanAI/python-func.git@v0.1.0
|
|
27
|
+
pip install git+https://github.com/stupidhumanAI/python-func.git@<commit-sha>
|
|
28
|
+
|
|
29
|
+
# SSH
|
|
30
|
+
pip install git+ssh://git@github.com/stupidhumanAI/python-func.git@v0.1.0
|
|
31
|
+
pip install git+ssh://git@github.com/stupidhumanAI/python-func.git@<commit-sha>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Inside a `requirements.txt` or `pyproject.toml`:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
# requirements.txt (HTTPS)
|
|
38
|
+
git+https://github.com/stupidhumanAI/python-func.git@v0.1.0
|
|
39
|
+
|
|
40
|
+
# requirements.txt (SSH)
|
|
41
|
+
git+ssh://git@github.com/stupidhumanAI/python-func.git@v0.1.0
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```toml
|
|
45
|
+
# pyproject.toml (HTTPS)
|
|
46
|
+
[project]
|
|
47
|
+
dependencies = [
|
|
48
|
+
"azure-func-decorator @ git+https://github.com/stupidhumanAI/python-func.git@v0.1.0",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# pyproject.toml (SSH)
|
|
52
|
+
[project]
|
|
53
|
+
dependencies = [
|
|
54
|
+
"azure-func-decorator @ git+ssh://git@github.com/stupidhumanAI/python-func.git@v0.1.0",
|
|
55
|
+
]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Public API
|
|
59
|
+
|
|
60
|
+
Two top-level classes are exported from `azure_func`:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from azure_func import AzureFunctionApp # simple HTTP routes
|
|
64
|
+
from azure_func import ServiceHandler # HTTP routes + OAuth2 scope guards
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
### `AzureFunctionApp`
|
|
70
|
+
|
|
71
|
+
A thin wrapper around `azure.functions.FunctionApp`. Use it when you do not
|
|
72
|
+
need scope-based access control.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
app = AzureFunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS, **kwargs)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
| Parameter | Type | Default | Description |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| `http_auth_level` | `func.AuthLevel` | `ANONYMOUS` | Passed straight to the underlying `FunctionApp` |
|
|
81
|
+
| `**kwargs` | — | — | Forwarded to `FunctionApp.__init__` |
|
|
82
|
+
|
|
83
|
+
**`app.route(route=None, methods=None)`** — decorator
|
|
84
|
+
|
|
85
|
+
| Parameter | Type | Default | Description |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| `route` | `str \| None` | function name | URL path for the HTTP trigger |
|
|
88
|
+
| `methods` | `list[str] \| None` | `["GET", "POST"]` | Accepted HTTP methods |
|
|
89
|
+
|
|
90
|
+
Returns the **original function unchanged**, so it can be called directly in
|
|
91
|
+
tests and other code without going through the HTTP layer.
|
|
92
|
+
|
|
93
|
+
**`app.func_app`** — the underlying `azure.functions.FunctionApp` instance.
|
|
94
|
+
|
|
95
|
+
#### Example
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
import azure.functions as func
|
|
99
|
+
from azure_func import AzureFunctionApp
|
|
100
|
+
|
|
101
|
+
app = AzureFunctionApp()
|
|
102
|
+
|
|
103
|
+
@app.route()
|
|
104
|
+
def full_name(fname, lname):
|
|
105
|
+
return fname + " " + lname
|
|
106
|
+
|
|
107
|
+
# GET /full_name?fname=John&lname=Doe → {"result": "John Doe"}
|
|
108
|
+
|
|
109
|
+
@app.route(route="api/greet", methods=["GET"])
|
|
110
|
+
def greet(name, greeting="Hello"):
|
|
111
|
+
return f"{greeting}, {name}!"
|
|
112
|
+
|
|
113
|
+
# GET /api/greet?name=World → {"result": "Hello, World!"}
|
|
114
|
+
|
|
115
|
+
# The original function is still directly callable:
|
|
116
|
+
assert full_name("John", "Doe") == "John Doe"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
### `ServiceHandler`
|
|
122
|
+
|
|
123
|
+
Like `AzureFunctionApp` but adds OAuth2/JWT scope validation via
|
|
124
|
+
`@sh.scope()`. Use it when endpoints must be protected by bearer tokens.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
sh = ServiceHandler(http_auth_level=func.AuthLevel.ANONYMOUS, **kwargs)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Constructor parameters are identical to `AzureFunctionApp`.
|
|
131
|
+
|
|
132
|
+
**`sh.service(route=None, methods=None)`** — decorator
|
|
133
|
+
|
|
134
|
+
Same parameters as `app.route()`. Reads any scope metadata placed on the
|
|
135
|
+
function by `@sh.scope()` and enforces it on every request.
|
|
136
|
+
|
|
137
|
+
**`sh.anonymous(route=None, methods=None)`** — decorator
|
|
138
|
+
|
|
139
|
+
Same parameters as `sh.service()`. Registers the function as a public HTTP
|
|
140
|
+
trigger with no authentication or scope requirements. Use this instead of
|
|
141
|
+
`@sh.service()` to make the intent explicit that the endpoint is publicly
|
|
142
|
+
accessible, even when other endpoints on the same handler use `@sh.scope()`.
|
|
143
|
+
|
|
144
|
+
**`sh.scope(*scopes)`** — decorator
|
|
145
|
+
|
|
146
|
+
Attaches required OAuth2 scope strings to a function. The check is an **AND**:
|
|
147
|
+
all listed scopes must be present in the token.
|
|
148
|
+
|
|
149
|
+
| Parameter | Type | Description |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `*scopes` | `str` | One or more scope strings to require |
|
|
152
|
+
|
|
153
|
+
`@sh.scope()` is **cumulative**: stacking multiple calls or listing multiple
|
|
154
|
+
scopes in one call both accumulate onto the same set of required scopes.
|
|
155
|
+
Scope metadata is stored on the function object under the attribute
|
|
156
|
+
`_required_scopes` and is read by `@sh.service()` at decoration time.
|
|
157
|
+
|
|
158
|
+
`@sh.scope()` must be applied **below** `@sh.service()` in the source (i.e.
|
|
159
|
+
closer to the function definition).
|
|
160
|
+
|
|
161
|
+
#### Example
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from azure_func import ServiceHandler
|
|
165
|
+
|
|
166
|
+
sh = ServiceHandler()
|
|
167
|
+
|
|
168
|
+
@sh.anonymous()
|
|
169
|
+
def health():
|
|
170
|
+
return "ok"
|
|
171
|
+
|
|
172
|
+
# GET /health → {"result": "ok"} (no token required)
|
|
173
|
+
|
|
174
|
+
@sh.service()
|
|
175
|
+
@sh.scope("read:items")
|
|
176
|
+
def get_item(item_id: int):
|
|
177
|
+
return {"id": item_id}
|
|
178
|
+
|
|
179
|
+
@sh.service(methods=["POST"])
|
|
180
|
+
@sh.scope("read:items")
|
|
181
|
+
@sh.scope("write:items") # cumulative — both scopes required
|
|
182
|
+
def create_item(name, price: float):
|
|
183
|
+
return {"name": name, "price": price}
|
|
184
|
+
|
|
185
|
+
# Equivalent single call:
|
|
186
|
+
# @sh.scope("read:items", "write:items")
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Parameter handling
|
|
192
|
+
|
|
193
|
+
Parameters are resolved from the HTTP request using this priority order:
|
|
194
|
+
|
|
195
|
+
1. **Query string** — `?key=value`
|
|
196
|
+
2. **Route params** — path segments defined in the route template (e.g. `route="items/{item_id}"`)
|
|
197
|
+
3. **JSON body** — `Content-Type: application/json` with `{"key": "value"}`
|
|
198
|
+
|
|
199
|
+
Query string always wins when the same key appears in multiple sources.
|
|
200
|
+
|
|
201
|
+
All incoming values arrive as strings. If a function parameter has a type
|
|
202
|
+
annotation the value is cast before the function is called.
|
|
203
|
+
|
|
204
|
+
### Type coercion
|
|
205
|
+
|
|
206
|
+
| Annotation | Behaviour |
|
|
207
|
+
|---|---|
|
|
208
|
+
| `int` | `int(value)` |
|
|
209
|
+
| `float` | `float(value)` |
|
|
210
|
+
| `bool` | `False` for `"false"`, `"0"`, `"no"`, `""`; `True` for everything else (case-insensitive) |
|
|
211
|
+
| `str` | no-op |
|
|
212
|
+
| any other callable | called with the string value |
|
|
213
|
+
|
|
214
|
+
Failed coercion returns **HTTP 400** before the function is invoked.
|
|
215
|
+
|
|
216
|
+
### Default values
|
|
217
|
+
|
|
218
|
+
Parameters with Python default values are optional in the request. Parameters
|
|
219
|
+
without defaults are required; omitting them returns **HTTP 400**.
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
@sh.service()
|
|
223
|
+
def calc(n: int, op: str = "double"):
|
|
224
|
+
return n * 2 if op == "double" else n
|
|
225
|
+
|
|
226
|
+
# GET /calc?n=5 → {"result": 10}
|
|
227
|
+
# GET /calc?n=5&op=id → {"result": 5}
|
|
228
|
+
# GET /calc → 400 {"error": "Missing required parameter(s): n"}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Route parameters are declared in the `route` template using `{param}` syntax and
|
|
232
|
+
resolved automatically like any other parameter:
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
@sh.service(route="items/{item_id}", methods=["GET"])
|
|
236
|
+
def get_item(item_id: int):
|
|
237
|
+
return {"id": item_id}
|
|
238
|
+
|
|
239
|
+
# GET /items/42 → {"result": {"id": 42}}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Response format
|
|
245
|
+
|
|
246
|
+
Every response body is JSON.
|
|
247
|
+
|
|
248
|
+
### Success
|
|
249
|
+
|
|
250
|
+
```json
|
|
251
|
+
{"result": <return value of the function>}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
HTTP status: **200**
|
|
255
|
+
|
|
256
|
+
### Error responses
|
|
257
|
+
|
|
258
|
+
| Situation | Status | Body |
|
|
259
|
+
|---|---|---|
|
|
260
|
+
| Missing required parameter(s) | 400 | `{"error": "Missing required parameter(s): x, y"}` |
|
|
261
|
+
| Type coercion failure | 400 | `{"error": "Invalid value for 'n': expected int, got 'abc'", "detail": "<exception message>"}` |
|
|
262
|
+
| Missing or insufficient scope | 403 | `{"error": "Insufficient scope", "required": ["scope1", ...]}` |
|
|
263
|
+
| Unhandled exception in function | 500 | `{"error": "Internal server error", "detail": "<exception message>"}` |
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## OAuth2 / JWT scope validation
|
|
268
|
+
|
|
269
|
+
When `@sh.scope()` is used, every request must include an `Authorization`
|
|
270
|
+
header with a Bearer JWT:
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
Authorization: Bearer <jwt>
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Scopes are read from the JWT payload (no signature verification is performed —
|
|
277
|
+
the Azure Functions host or an API gateway is expected to validate the token):
|
|
278
|
+
|
|
279
|
+
- `scp` claim — Azure AD format, space-separated string
|
|
280
|
+
- `scope` claim — standard OAuth2, space-separated string or list
|
|
281
|
+
|
|
282
|
+
If the header is absent, malformed, or the token does not contain all required
|
|
283
|
+
scopes, the handler returns **HTTP 403** and the function is never called.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Project layout for Azure Functions
|
|
288
|
+
|
|
289
|
+
A minimal Azure Functions v2 (Python) project using this library:
|
|
290
|
+
|
|
291
|
+
```
|
|
292
|
+
my_project/
|
|
293
|
+
├── function_app.py ← entry point; register all routes here
|
|
294
|
+
├── host.json
|
|
295
|
+
├── local.settings.json
|
|
296
|
+
└── requirements.txt
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**`function_app.py`**:
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
import azure.functions as func
|
|
303
|
+
from azure_func import AzureFunctionApp
|
|
304
|
+
|
|
305
|
+
app = AzureFunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
|
|
306
|
+
|
|
307
|
+
@app.route()
|
|
308
|
+
def hello(name: str):
|
|
309
|
+
return f"Hello, {name}!"
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
The `app` (or `sh.func_app` when using `ServiceHandler`) must be named `app`
|
|
313
|
+
in `function_app.py` — that is what the Azure Functions runtime discovers.
|
|
314
|
+
|
|
315
|
+
When using `ServiceHandler`, expose `sh.func_app` as `app`:
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
from azure_func import ServiceHandler
|
|
319
|
+
import azure.functions as func
|
|
320
|
+
|
|
321
|
+
sh = ServiceHandler(http_auth_level=func.AuthLevel.ANONYMOUS)
|
|
322
|
+
app = sh.func_app # runtime entry point
|
|
323
|
+
|
|
324
|
+
@sh.service()
|
|
325
|
+
@sh.scope("api:access")
|
|
326
|
+
def protected(value: str):
|
|
327
|
+
return value.upper()
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## Queue triggers
|
|
333
|
+
|
|
334
|
+
`@sh.queue(queue_name)` registers a function as an Azure Storage Queue trigger.
|
|
335
|
+
The function is called automatically whenever a message arrives on the specified
|
|
336
|
+
queue, receiving a `func.QueueMessage` object.
|
|
337
|
+
|
|
338
|
+
### `sh.queue(queue_name, connection="AzureWebJobsStorage")` — decorator
|
|
339
|
+
|
|
340
|
+
| Parameter | Type | Default | Description |
|
|
341
|
+
|---|---|---|---|
|
|
342
|
+
| `queue_name` | `str` | — | Name of the storage queue to listen on |
|
|
343
|
+
| `connection` | `str` | `"AzureWebJobsStorage"` | App setting name for the storage connection string |
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
import azure.functions as func
|
|
347
|
+
from azure_func import ServiceHandler
|
|
348
|
+
|
|
349
|
+
sh = ServiceHandler()
|
|
350
|
+
app = sh.func_app
|
|
351
|
+
|
|
352
|
+
@sh.queue("my-queue")
|
|
353
|
+
def process_message(msg: func.QueueMessage):
|
|
354
|
+
data = msg.get_json() # parse JSON body
|
|
355
|
+
raw = msg.get_body().decode() # raw string body
|
|
356
|
+
print(f"Received: {data}")
|
|
357
|
+
|
|
358
|
+
@sh.queue("orders", connection="OrdersStorageConnection")
|
|
359
|
+
def process_order(msg: func.QueueMessage):
|
|
360
|
+
order = msg.get_json()
|
|
361
|
+
...
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Unhandled exceptions are logged and re-raised, which causes the Azure Functions
|
|
365
|
+
runtime to retry the message according to the queue's poison-message policy.
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## Streaming responses
|
|
370
|
+
|
|
371
|
+
`@sh.stream()` registers an async generator function as an HTTP trigger that
|
|
372
|
+
streams its output using the
|
|
373
|
+
[`azurefunctions-extensions-http-fastapi`](https://pypi.org/project/azurefunctions-extensions-http-fastapi/)
|
|
374
|
+
extension. This is the correct approach for SSE, LLM token streaming, and any
|
|
375
|
+
response too large to buffer.
|
|
376
|
+
|
|
377
|
+
All handlers (`@sh.service()`, `@sh.anonymous()`, `@sh.stream()`) now use
|
|
378
|
+
the FastAPI-compatible `Request` type from this extension, so the request model
|
|
379
|
+
is consistent across the whole app.
|
|
380
|
+
|
|
381
|
+
### `sh.stream(route=None, methods=None, media_type="text/event-stream")` — decorator
|
|
382
|
+
|
|
383
|
+
| Parameter | Type | Default | Description |
|
|
384
|
+
|---|---|---|---|
|
|
385
|
+
| `route` | `str \| None` | function name | URL path for the HTTP trigger |
|
|
386
|
+
| `methods` | `list[str] \| None` | `["GET", "POST"]` | Accepted HTTP methods |
|
|
387
|
+
| `media_type` | `str` | `"text/event-stream"` | `Content-Type` of the streamed response |
|
|
388
|
+
|
|
389
|
+
The decorated function must be an **async generator** (`async def` with `yield`).
|
|
390
|
+
Parameters are extracted and type-coerced exactly like `@sh.service()`.
|
|
391
|
+
`@sh.scope()` is supported and enforced before streaming begins.
|
|
392
|
+
|
|
393
|
+
```python
|
|
394
|
+
from azure_func import ServiceHandler
|
|
395
|
+
|
|
396
|
+
sh = ServiceHandler()
|
|
397
|
+
app = sh.func_app
|
|
398
|
+
|
|
399
|
+
@sh.stream()
|
|
400
|
+
async def count(n: int):
|
|
401
|
+
"""Stream n numbers as Server-Sent Events."""
|
|
402
|
+
for i in range(n):
|
|
403
|
+
yield f"data: {i}\n\n"
|
|
404
|
+
|
|
405
|
+
# GET /count?n=3 → streams:
|
|
406
|
+
# data: 0
|
|
407
|
+
#
|
|
408
|
+
# data: 1
|
|
409
|
+
#
|
|
410
|
+
# data: 2
|
|
411
|
+
#
|
|
412
|
+
|
|
413
|
+
@sh.stream(media_type="application/x-ndjson")
|
|
414
|
+
@sh.scope("read:data")
|
|
415
|
+
async def search_results(query: str):
|
|
416
|
+
"""Stream search results as newline-delimited JSON."""
|
|
417
|
+
import json
|
|
418
|
+
for result in run_search(query):
|
|
419
|
+
yield json.dumps(result) + "\n"
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Required environment variables
|
|
423
|
+
|
|
424
|
+
Add these to `local.settings.json` for local development and to Azure App
|
|
425
|
+
Settings for deployed apps:
|
|
426
|
+
|
|
427
|
+
```json
|
|
428
|
+
{
|
|
429
|
+
"IsEncrypted": false,
|
|
430
|
+
"Values": {
|
|
431
|
+
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
432
|
+
"FUNCTIONS_WORKER_RUNTIME": "python",
|
|
433
|
+
"PYTHON_ENABLE_INIT_INDEXING": "1",
|
|
434
|
+
"PYTHON_ISOLATE_WORKER_DEPENDENCIES": "1"
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
`PYTHON_ENABLE_INIT_INDEXING=1` is required for all deployments.
|
|
440
|
+
`PYTHON_ISOLATE_WORKER_DEPENDENCIES=1` is required on the Linux Consumption
|
|
441
|
+
plan and recommended elsewhere to avoid protobuf/grpcio conflicts.
|
|
442
|
+
|
|
443
|
+
### Limitations
|
|
444
|
+
|
|
445
|
+
- Does not work on **Premium** or **Dedicated** hosting plans (known Azure issue)
|
|
446
|
+
- Works on **Consumption** and **Flex Consumption** plans
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
## Static files
|
|
451
|
+
|
|
452
|
+
`@sh.static(folder)` registers a catch-all GET route that serves files from a
|
|
453
|
+
local folder at the root URL path.
|
|
454
|
+
|
|
455
|
+
```python
|
|
456
|
+
@sh.static('public')
|
|
457
|
+
def serve_static():
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
# GET /style.css → ./public/style.css
|
|
461
|
+
# GET /js/app.js → ./public/js/app.js
|
|
462
|
+
# GET /images/logo.png → ./public/images/logo.png
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
| Situation | Status |
|
|
466
|
+
|---|---|
|
|
467
|
+
| File found | 200 with correct `Content-Type` |
|
|
468
|
+
| File not found | 404 |
|
|
469
|
+
| Path traversal attempt | 404 |
|
|
470
|
+
|
|
471
|
+
The `folder` argument is relative to the function app's working directory.
|
|
472
|
+
MIME types are detected automatically from the file extension.
|
|
473
|
+
|
|
474
|
+
Because the catch-all route `{*filepath}` has lower specificity than named
|
|
475
|
+
routes, all your `@sh.service()` and `@sh.tool()` endpoints continue to take
|
|
476
|
+
precedence.
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
## MCP tools
|
|
481
|
+
|
|
482
|
+
`ServiceHandler` supports registering functions as [Azure Functions MCP tool
|
|
483
|
+
triggers](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-mcp-tool-trigger)
|
|
484
|
+
via `@sh.tool()`. This turns your function app into a remote MCP server that
|
|
485
|
+
any MCP client (e.g. VS Code Agent Mode, Claude Desktop) can connect to.
|
|
486
|
+
|
|
487
|
+
Requires `azure-functions >= 1.25.0b2` (still in pre-release — install explicitly
|
|
488
|
+
with `pip install "azure-functions>=1.25.0b2"`) and the **preview extension bundle** in
|
|
489
|
+
`host.json` (included in this repo).
|
|
490
|
+
|
|
491
|
+
### `sh.tool()` — decorator
|
|
492
|
+
|
|
493
|
+
Registers the function as an MCP tool trigger. The **function name** becomes
|
|
494
|
+
the tool name and the **docstring** becomes the tool description. Each
|
|
495
|
+
parameter is automatically registered as an MCP tool property.
|
|
496
|
+
|
|
497
|
+
```python
|
|
498
|
+
from azure_func import ServiceHandler
|
|
499
|
+
|
|
500
|
+
sh = ServiceHandler()
|
|
501
|
+
app = sh.func_app # runtime entry point
|
|
502
|
+
|
|
503
|
+
@sh.tool()
|
|
504
|
+
def hello() -> str:
|
|
505
|
+
"""Say hello."""
|
|
506
|
+
return "Hello from MCP!"
|
|
507
|
+
|
|
508
|
+
@sh.tool()
|
|
509
|
+
@sh.tool_property("item_id", "The ID of the item to retrieve.")
|
|
510
|
+
def get_item(item_id: int) -> str:
|
|
511
|
+
"""Get an item by ID."""
|
|
512
|
+
return str(item_id)
|
|
513
|
+
|
|
514
|
+
@sh.tool()
|
|
515
|
+
@sh.tool_property("name", "The name of the item.")
|
|
516
|
+
@sh.tool_property("price", "The price of the item in USD.")
|
|
517
|
+
def create_item(name: str, price: float) -> str:
|
|
518
|
+
"""Create a new item with a name and price."""
|
|
519
|
+
return f"Created {name} at ${price}"
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
Property descriptions are optional — parameters without a `@sh.tool_property()`
|
|
523
|
+
are still registered as MCP tool properties, just without a description.
|
|
524
|
+
|
|
525
|
+
### Authentication
|
|
526
|
+
|
|
527
|
+
MCP tools are **not** protected by `@sh.scope()`. Instead, authentication is
|
|
528
|
+
handled at the Azure host level via the `mcp_extension` system key. When
|
|
529
|
+
deployed, clients must include this key either as a `?code=` query parameter
|
|
530
|
+
or as the `x-functions-key` header.
|
|
531
|
+
|
|
532
|
+
Retrieve the key from the portal (**Functions → App keys → System keys →
|
|
533
|
+
`mcp_extension`**) or via the CLI:
|
|
534
|
+
|
|
535
|
+
```bash
|
|
536
|
+
az functionapp keys list --name <app> --resource-group <rg> --query systemKeys
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Connecting an MCP client
|
|
540
|
+
|
|
541
|
+
**VS Code (Agent Mode)** — add to `.vscode/mcp.json`:
|
|
542
|
+
|
|
543
|
+
```json
|
|
544
|
+
{
|
|
545
|
+
"servers": {
|
|
546
|
+
"my-func-mcp": {
|
|
547
|
+
"type": "http",
|
|
548
|
+
"url": "https://<funcappname>.azurewebsites.net/runtime/webhooks/mcp",
|
|
549
|
+
"headers": {
|
|
550
|
+
"x-functions-key": "<mcp_extension key>"
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
**MCP Inspector** — use the URL with `?code=<mcp_extension key>` appended.
|
|
558
|
+
|
|
559
|
+
### host.json
|
|
560
|
+
|
|
561
|
+
The `mcpToolTrigger` binding is a preview feature and requires the preview
|
|
562
|
+
extension bundle. The `host.json` in this repo is already configured:
|
|
563
|
+
|
|
564
|
+
```json
|
|
565
|
+
{
|
|
566
|
+
"version": "2.0",
|
|
567
|
+
"extensionBundle": {
|
|
568
|
+
"id": "Microsoft.Azure.Functions.ExtensionBundle.Preview",
|
|
569
|
+
"version": "[4.*, 5.0.0)"
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## Running tests
|
|
577
|
+
|
|
578
|
+
```bash
|
|
579
|
+
pip install -e '.[dev]'
|
|
580
|
+
pytest
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
Tests mock the Azure SDK (`azure.functions`) entirely, so no Azure account or
|
|
584
|
+
Functions runtime is needed.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import azure.functions as func
|
|
2
|
+
|
|
3
|
+
from .decorator import make_http_handler
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AzureFunctionApp:
|
|
7
|
+
"""
|
|
8
|
+
Thin wrapper around ``azure.functions.FunctionApp`` that adds a
|
|
9
|
+
``@app.route()`` decorator for turning plain Python functions into
|
|
10
|
+
Azure HTTP-triggered functions.
|
|
11
|
+
|
|
12
|
+
Usage::
|
|
13
|
+
|
|
14
|
+
app = AzureFunctionApp()
|
|
15
|
+
|
|
16
|
+
@app.route()
|
|
17
|
+
def full_name(fname, lname):
|
|
18
|
+
return fname + " " + lname
|
|
19
|
+
|
|
20
|
+
# The underlying FunctionApp is available as app.func_app
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, http_auth_level=func.AuthLevel.ANONYMOUS, **kwargs):
|
|
24
|
+
self.func_app = func.FunctionApp(http_auth_level=http_auth_level, **kwargs)
|
|
25
|
+
|
|
26
|
+
# Proxy attribute access so callers can do ``app.func_app`` things directly
|
|
27
|
+
def __getattr__(self, name):
|
|
28
|
+
return getattr(self.func_app, name)
|
|
29
|
+
|
|
30
|
+
def route(self, route: str = None, methods: list = None):
|
|
31
|
+
"""
|
|
32
|
+
Decorator that registers the wrapped function as an Azure HTTP trigger.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
route: URL path segment. Defaults to the function's own name.
|
|
36
|
+
methods: HTTP methods to accept. Defaults to ``["GET", "POST"]``.
|
|
37
|
+
|
|
38
|
+
The decorated function is returned unchanged so it can still be
|
|
39
|
+
called directly in tests or other Python code.
|
|
40
|
+
"""
|
|
41
|
+
if methods is None:
|
|
42
|
+
methods = ["GET", "POST"]
|
|
43
|
+
|
|
44
|
+
def decorator(fn):
|
|
45
|
+
route_name = route or fn.__name__
|
|
46
|
+
return make_http_handler(fn, self.func_app, route_name, methods)
|
|
47
|
+
|
|
48
|
+
return decorator
|